diff --git a/.github/workflows/laravel.yml b/.github/workflows/feature-tests.yml similarity index 98% rename from .github/workflows/laravel.yml rename to .github/workflows/feature-tests.yml index 6fc9dade..d5805489 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/feature-tests.yml @@ -47,7 +47,7 @@ jobs: - name: Set up PHP & Composer uses: shivammathur/setup-php@v2 with: - php-version: 8.3 + php-version: 8.4 extensions: mbstring, pdo, mysql, bcmath, tokenizer tools: composer diff --git a/.github/workflows/review-dog.yml b/.github/workflows/review-dog.yml new file mode 100644 index 00000000..064626ec --- /dev/null +++ b/.github/workflows/review-dog.yml @@ -0,0 +1,39 @@ +name: Laravel Pint Code Review + +on: + pull_request: + paths: + - '**.php' + +jobs: + reviewdog: + name: Laravel Pint Review + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer + extensions: mbstring, dom, curl + + - name: Install Dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Install Pint + run: composer global require laravel/pint + + - name: Run Pint with ReviewDog + env: + REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + ~/.composer/vendor/bin/pint --test --format=checkstyle \ + | npx reviewdog -f=checkstyle \ + -name="Laravel Pint" \ + -reporter=github-pr-review \ + -fail-on-error=false \ + -level=info diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 9738772d..89d0234d 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -20,7 +20,7 @@ public function update(User $user, array $input): void Validator::make($input, [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], - 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], + 'photo' => ['nullable', 'image', 'max:400'], ])->validateWithBag('updateProfileInformation'); if (isset($input['photo'])) { diff --git a/app/Events/CommentCreated.php b/app/Events/CommentCreated.php deleted file mode 100644 index 49a01826..00000000 --- a/app/Events/CommentCreated.php +++ /dev/null @@ -1,37 +0,0 @@ - - */ - public function broadcastOn(): array - { - return [ - new PrivateChannel('channel-name'), - ]; - } -} diff --git a/app/Events/ResourceReviewProcessed.php b/app/Events/ResourceReviewProcessed.php index 563889e9..bcd2e72e 100644 --- a/app/Events/ResourceReviewProcessed.php +++ b/app/Events/ResourceReviewProcessed.php @@ -2,11 +2,8 @@ namespace App\Events; -use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PresenceChannel; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -20,8 +17,8 @@ class ResourceReviewProcessed implements ShouldDispatchAfterCommit */ public function __construct( public int $resource, - public array|null $oldReview, - public array|null $newReview, + public ?array $oldReview, + public ?array $newReview, ) {} /** diff --git a/app/Events/TagFrequencyChanged.php b/app/Events/TagFrequencyChanged.php index 2c9c3868..87a5e28c 100644 --- a/app/Events/TagFrequencyChanged.php +++ b/app/Events/TagFrequencyChanged.php @@ -2,11 +2,8 @@ namespace App\Events; -use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PresenceChannel; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -17,7 +14,7 @@ class TagFrequencyChanged /** * Create a new event instance. */ - public function __construct(public array|null $oldTags, public array|null $newTags) { } + public function __construct(public ?array $oldTags, public ?array $newTags) {} /** * Get the channels the event should broadcast on. diff --git a/app/Events/UpvoteProcessed.php b/app/Events/UpvoteProcessed.php index c94aa3a0..9b7d12f1 100644 --- a/app/Events/UpvoteProcessed.php +++ b/app/Events/UpvoteProcessed.php @@ -2,26 +2,20 @@ namespace App\Events; -use App\Models\Upvote; -use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; -use Illuminate\Broadcasting\PresenceChannel; -use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Broadcasting\PrivateChannel; -use Illuminate\Contracts\Broadcasting\ShouldBroadcast; +use Illuminate\Contracts\Events\ShouldDispatchAfterCommit; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; -class UpvoteProcessed implements ShouldDispatchAfterCommit +class UpvoteProcessed implements ShouldDispatchAfterCommit { use Dispatchable, InteractsWithSockets, SerializesModels; /** * Create a new event instance. */ - public function __construct(public string $type, public int $id, public int $previousValue, public int $newValue) - { - } + public function __construct(public string $type, public int $id, public int $previousValue, public int $newValue) {} /** * Get the channels the event should broadcast on. diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index 0f21b850..3d3a3d8e 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -2,13 +2,12 @@ namespace App\Http\Controllers; -use App\Events\CommentCreated; use App\Http\Requests\Comment\StoreCommentRequest; use App\Http\Resources\CommentResource; -use App\Models\Comment; -use App\Services\ModelResolverService; use App\Http\Resources\UserResource; +use App\Models\Comment; use App\Services\CommentService; +use App\Services\ModelResolverService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; @@ -18,6 +17,7 @@ class CommentController extends Controller { protected $modelResolver; + protected $commentService; public function __construct(ModelResolverService $modelResolver, CommentService $commentService) @@ -31,10 +31,9 @@ public function __construct(ModelResolverService $modelResolver, CommentService */ public function store(StoreCommentRequest $request) { - Log::debug("Called store on comment controller"); $validatedData = $request->validated(); + Log::debug('Comment Controller Store', ['validated data' => $validatedData]); - Log::debug("Data validated and is " . json_encode($validatedData)); $comment = new Comment; $comment->content = $validatedData['content']; $comment->user_id = Auth::id(); @@ -44,10 +43,9 @@ public function store(StoreCommentRequest $request) // Ensure that the model exists $model = $this->modelResolver->resolve($validatedData['commentable_key'], $commentableId); - if (!$model) { + if (! $model) { return response()->json(['message' => 'Model not found'], 404); } - Log::debug("Resolved model class: " . $commentableType); // Set the commentable type $comment->commentable_type = $commentableType; @@ -55,7 +53,7 @@ public function store(StoreCommentRequest $request) // Top level comment $parentCommentId = $validatedData['parent_comment_id']; - if (!$parentCommentId) { + if (! $parentCommentId) { $comment->parent_comment_id = null; $comment->depth = 1; $comment->children_count = 0; @@ -84,7 +82,7 @@ public function store(StoreCommentRequest $request) 'commentable_id' => $commentableId, 'commentable_type' => $commentableType, 'depth' => $new_comment_depth, - 'replies_count' => $replies_count + 'replies_count' => $replies_count, ], [ 'commentable_id' => [ @@ -99,14 +97,14 @@ public function store(StoreCommentRequest $request) 'depth' => [ 'required', 'integer', - 'lte:' . (config('comment.max_depth')) + 'lte:'.(config('comment.max_depth')), ], // Cannot exceed max replies 'replies_count' => [ 'required', 'integer', - 'lt:' . (config('comment.max_replies')) - ] + 'lt:'.(config('comment.max_replies')), + ], ] ); @@ -125,16 +123,19 @@ public function store(StoreCommentRequest $request) } $comment->save(); - CommentCreated::dispatch( - $commentableId, - $commentableType - ); - Log::debug("New saved comment is " . json_encode($comment)); + Log::debug('New comment saved', [ + 'comment_id' => $comment->id, + 'user_id' => $comment->user_id, + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + 'depth' => $comment->depth, + ]); + return response()->json([ 'new_comment' => new CommentResource($comment), 'user' => new UserResource(Auth::user()), - ]);; + ]); } /** @@ -142,14 +143,19 @@ public function store(StoreCommentRequest $request) */ public function show(Request $request, string $commentableKey, int $commentableId, int $index, int $paginationLimit = -1) { - if ($paginationLimit == -1) - { + if ($paginationLimit == -1) { $paginationLimit = config('comment.default_pagination_limit'); } $sortBy = $request->query('sort_by', 'top'); - Log::debug("Request is, commentable_type: " . $commentableKey . ". id: " . $commentableId . ". index: " . $index . ". Sorting: " . $sortBy); + Log::debug('Processing comment show request', [ + 'commentable_key' => $commentableKey, + 'commentable_id' => $commentableId, + 'index' => $index, + 'sort_by' => $sortBy, + 'pagination_limit' => $paginationLimit, + ]); $paginatedResults = $this->commentService->getPaginatedComments($commentableKey, $commentableId, $index, $paginationLimit, $sortBy); return $paginatedResults; diff --git a/app/Http/Controllers/ComputerScienceResourceController.php b/app/Http/Controllers/ComputerScienceResourceController.php index 07abfb47..956b5896 100644 --- a/app/Http/Controllers/ComputerScienceResourceController.php +++ b/app/Http/Controllers/ComputerScienceResourceController.php @@ -15,18 +15,20 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Validator; -use Illuminate\Validation\Rule; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; class ComputerScienceResourceController extends Controller { protected $commentService; + protected $generalVotesSortingManager; + protected $reviewService; + protected $resourceSortingManager; - function __construct( + public function __construct( CommentService $commentService, GeneralVotesSortingManager $generalVotesSortingManager, ResourceReviewService $reviewService, @@ -71,12 +73,18 @@ public function create() public function store(StoreResourceRequest $request) { $validatedData = $request->validated(); - Log::debug("Called store resource with data " . json_encode($request)); + Log::debug('Called store resource with data '.json_encode($request)); + + // Store the image onto storage + $path = null; + if (array_key_exists('image_file', $validatedData) && $imageFile = $validatedData['image_file']) { + $path = $imageFile->store('resource', 'public'); + } $resource = ComputerScienceResource::create([ 'user_id' => Auth::id(), 'name' => $validatedData['name'], - 'image_url' => $validatedData['image_url'], + 'image_path' => $path, 'description' => $validatedData['description'], 'page_url' => $validatedData['page_url'], 'platforms' => $validatedData['platforms'], @@ -97,10 +105,10 @@ public function store(StoreResourceRequest $request) $resource->general_tags = $validatedData['general_tags']; } - // Change tag frequency + // Dispatch tag frequency change event TagFrequencyChanged::dispatch(null, $resource->tagCounter()); - Log::debug("Created resource " . json_encode($resource)); + Log::debug('Created resource '.json_encode($resource)); return redirect(route('resources.show', ['computerScienceResource' => $resource->id])) ->with('success', 'Created Resource Succesfully!'); @@ -111,9 +119,13 @@ public function store(StoreResourceRequest $request) */ public function show(Request $request, ComputerScienceResource $computerScienceResource, string $tab = 'reviews') { + // Get the review summaries + $computerScienceResource->load('reviewSummary'); + $computerScienceResource->load('user'); + $validTabs = ['reviews', 'discussion', 'edits']; - if (!in_array($tab, $validTabs)) { + if (! in_array($tab, $validTabs)) { // Redirect to default if invalid return redirect()->route('resources.show', [ 'computerScienceResource' => $computerScienceResource->id, @@ -130,32 +142,40 @@ public function show(Request $request, ComputerScienceResource $computerScienceR $sortBy = $request->query('sort_by', 'top'); // Load only the necessary tab data if ($tab === 'reviews') { + $userReview = null; + if ($userId = Auth::id()) { + $userReview = ResourceReview::where('user_id', $userId)->first(); + } + + $data['userReview'] = $userReview; + $data['reviews'] = Inertia::defer( - function () use ($computerScienceResource, $sortBy) { + function () use ($computerScienceResource, $sortBy, $request) { $query = ResourceReview::where('computer_science_resource_id', $computerScienceResource->id); $query = $this->generalVotesSortingManager->applySort($query, $sortBy, ResourceReview::class); - return $query->get(); + + return $query->with('user')->paginate(10)->appends($request->query()); } ); } elseif ($tab === 'edits') { $data['resourceEdits'] = Inertia::defer( - function () use ($computerScienceResource, $sortBy) { + function () use ($computerScienceResource, $sortBy, $request) { $query = ResourceEdits::where('computer_science_resource_id', $computerScienceResource->id); + // TODO: ADD ERROR LOGS IF THIS MAKES IT RETURN NOTHING, SORTING SHOULD NOT CHANGE SIZE, ONLY ORDER $query = $this->generalVotesSortingManager->applySort($query, $sortBy, ResourceEdits::class); - return $query->get(); + + return $query->with('user')->paginate(10)->appends($request->query()); } ); } elseif ($tab === 'discussion') { $data['discussion'] = Inertia::defer( - fn() => - $this->commentService->getPaginatedComments('resource', $computerScienceResource->id, 0, 150, $sortBy) + fn () => $this->commentService->getPaginatedComments('resource', $computerScienceResource->id, 0, 150, $sortBy) ); } return Inertia::render('Resources/Show', $data); } - /** * Remove the specified resource from storage. */ diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index 23dbde7b..23bd12d1 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -3,17 +3,27 @@ namespace App\Http\Controllers; use App\Events\TagFrequencyChanged; +use App\Http\Requests\ResourceEdit\StoreResourceEdit; use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; +use App\Services\DataNormalizationService; use App\Services\ResourceEditsService; -use App\Http\Requests\ResourceEdit\StoreResourceEdit; -use App\Http\Resources\ComputerScienceResourceResource; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Storage; use Inertia\Inertia; use Log; +use Str; class ResourceEditsController extends Controller { + private DataNormalizationService $dataService; + + public function __construct( + private DataNormalizationService $dataNormalizationService + ) { + $this->dataService = $dataNormalizationService; + } + /** * Return the form to create a edit. */ @@ -22,37 +32,33 @@ public function create(ComputerScienceResource $computerScienceResource) $computerScienceResource->load('user'); return Inertia::render('ResourceEdits/Create', [ - 'resource' => fn() => $computerScienceResource + 'resource' => fn () => $computerScienceResource, ]); } - - // TODO: Make an array facade or something - function normalize($array) { - ksort($array); - foreach ($array as &$value) { - if (is_array($value)) { - sort($value); // Assumes it's a flat array of values - } - } - return $array; - } - /** * Store the edits request. */ public function store(ComputerScienceResource $computerScienceResource, StoreResourceEdit $request) { $validatedData = $request->validated(); - Log::debug("Creating a resource edit: " . json_encode($validatedData)); - - // Ensure that they are not the same - $originalData = $this->normalize((new ComputerScienceResourceResource($computerScienceResource))->resolve()); - $editData = $this->normalize($validatedData); - unset($editData['edit_title'], $editData['edit_description']); - - if ($originalData == $editData) { - return response()->json(['message' => 'No changes detected'], 422); + $proposedChanges = $validatedData['proposed_changes'] ?? []; + + $actualChanges = $this->calculateChanges($computerScienceResource, $proposedChanges); + + if (array_key_exists('image_file', $proposedChanges)) { + $actualChanges['image_path'] = null; + if (isset($proposedChanges['image_file'])) { + $path = $proposedChanges['image_file']->store('resource-edits', 'public'); + $actualChanges['image_path'] = $path; + } + unset($actualChanges['image_file']); + } + + if (empty($actualChanges)) { + Log::warning("Resource edit was submitted without any changes for resource ID: {$computerScienceResource->id}"); + + return redirect()->back()->with('warning', 'Cannot submit an edit with no changes made.'); } $resourceEdit = ResourceEdits::create([ @@ -60,65 +66,101 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes 'computer_science_resource_id' => $computerScienceResource->id, 'edit_title' => $validatedData['edit_title'], 'edit_description' => $validatedData['edit_description'], - 'image_url' => $validatedData['image_url'], - 'name' => $validatedData['name'], - 'description' => $validatedData['description'], - 'page_url' => $validatedData['page_url'], - 'platforms' => $validatedData['platforms'], - 'difficulty' => $validatedData['difficulty'], - 'pricing' => $validatedData['pricing'], - 'topic_tags' => $validatedData['topic_tags'], - 'programming_language_tags' => $validatedData['programming_language_tags'], - 'general_tags' => $validatedData['general_tags'], + 'proposed_changes' => $actualChanges, ]); return redirect()->route('resource_edits.show', ['resourceEdits' => $resourceEdit->id]) - ->with('success', 'The proposed edits were created. Other\'s can now view it.'); + ->with('success', 'The proposed edits were created. Others can now view it.'); + } + + /** + * Calculate the actual differences between the proposed changes and the original resource. + */ + private function calculateChanges(ComputerScienceResource $resource, array $proposedChanges): array + { + $actualChanges = []; + $normalizedProposed = $this->dataNormalizationService->normalize($proposedChanges); + $normalizedOriginal = $this->dataNormalizationService->normalize($resource->toArray()); + + foreach ($normalizedProposed as $key => $value) { + if (! array_key_exists($key, $normalizedOriginal) || $normalizedOriginal[$key] !== $value) { + // Use the original value from the request, not the normalized one, for file uploads. + $actualChanges[$key] = $proposedChanges[$key]; + } + } + + return $actualChanges; } public function show(ResourceEdits $resourceEdits) { $resourceEdits->load('resource'); + $resourceEdits->load('user'); return Inertia::render('ResourceEdits/Show', [ - 'resourceId' => $resourceEdits->id, - 'originalResource' => fn () => $resourceEdits->resource, 'editedResource' => fn () => $resourceEdits, ]); } public function merge(ResourceEditsService $editsService, ResourceEdits $resourceEdits) { - if (!$editsService->canMergeEdits($resourceEdits)) { + if (! $editsService->canMergeEdits($resourceEdits)) { return redirect()->back()->with('warning', 'Not enough approvals'); } $resource = ComputerScienceResource::findOrFail($resourceEdits->computer_science_resource_id); - $old_tag_counter = $resource->tagCounter(); - - $resource->name = $resourceEdits->name; - $resource->description = $resourceEdits->description; - $resource->image_url = $resourceEdits->image_url; - $resource->page_url = $resourceEdits->page_url; - $resource->platforms = $resourceEdits->platforms; - $resource->difficulty = $resourceEdits->difficulty; - $resource->pricing = $resourceEdits->pricing; - + $oldTagCounter = $resource->tagCounter(); + + // Go through each property in proposed_changes, and if it exists. then set the value + $changes = $resourceEdits->proposed_changes; + $proposedFields = ['name', 'description', 'page_url', 'platforms', 'difficulty', 'pricing']; + foreach ($proposedFields as $field) { + if (array_key_exists($field, $changes)) { + $resource->$field = $changes[$field]; + } + } + + if (array_key_exists('image_path', $changes)) { + // Delete the existing resource image from storage + if ($resource->image_path) { + Storage::disk('public')->delete($resource->image_path); + } + + $destPath = null; + if (isset($changes['image_path'])) { + // Move the new file from 'resource-edits' to 'resource' + $sourcePath = $changes['image_path']; + $fileExtension = pathinfo($sourcePath, PATHINFO_EXTENSION); + $newFileName = Str::random(40).'.'.$fileExtension; + $destPath = 'resource/'.$newFileName; + + Storage::disk('public')->move($sourcePath, $destPath); + } + + // Update image_path in DB + $resource->image_path = $destPath; + } + $resource->save(); - - $resource->topic_tags = $resourceEdits->topic_tags; - $resource->programming_language_tags = $resourceEdits->programming_language_tags; - $resource->general_tags = $resourceEdits->general_tags; + + $proposedTagFields = ['topic_tags', 'programming_language_tags', 'general_tags']; + $allTags = []; + foreach ($proposedTagFields as $field) { + if (array_key_exists($field, $changes)) { + $resource->$field = $changes[$field]; + $allTags[] = $changes[$field]; + } + } // Get the new tag counter - $new_tags = collect([$resourceEdits->topic_tags, $resourceEdits->programming_language_tags, $resourceEdits->general_tags])->flatten()->countBy()->toArray(); + $newTags = collect($allTags)->flatten()->countBy()->toArray(); // Change tag frequency - TagFrequencyChanged::dispatch($old_tag_counter, $new_tags); + TagFrequencyChanged::dispatch($oldTagCounter, $newTags); // Delete the edit since we successfully merged the changes $resourceEdits->delete(); - return redirect(route('resources.show', ['computerScienceResource'=>$resourceEdits->computer_science_resource_id])) + return redirect(route('resources.show', ['computerScienceResource' => $resourceEdits->computer_science_resource_id])) ->with('success', 'Successfully merged new changed!'); } } diff --git a/app/Http/Controllers/ResourceReviewController.php b/app/Http/Controllers/ResourceReviewController.php index d7954e3f..319ba03e 100644 --- a/app/Http/Controllers/ResourceReviewController.php +++ b/app/Http/Controllers/ResourceReviewController.php @@ -2,7 +2,6 @@ namespace App\Http\Controllers; -use App\Events\ResourceReviewProcessed; use App\Http\Requests\ResourceReview\StoreResourceReview; use App\Models\ComputerScienceResource; use App\Models\ResourceReview; @@ -23,15 +22,22 @@ public function store(StoreResourceReview $request, ComputerScienceResource $com ])->first(); if ($existingReview) { - Log::debug("User has already posted a review"); - // TODO: Make it a json with errors instead - return back()->with('warning', 'You already have a review posted, you should edit your existing one instead.'); + Log::warning("User has already posted a review, can't make a new one", [ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + ]); + + return response()->json([], 400); } - Log::debug("Storing resource review: " . json_encode($validatedData)); + Log::debug('Storing resource review', [ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + 'review_data' => $validatedData, + ]); // Create the resource review - $review = ResourceReview::create([ + ResourceReview::create([ 'user_id' => Auth::id(), 'computer_science_resource_id' => $computerScienceResource->id, 'title' => $validatedData['title'], @@ -46,8 +52,6 @@ public function store(StoreResourceReview $request, ComputerScienceResource $com 'cons' => $validatedData['cons'], ]); - ResourceReviewProcessed::dispatch($computerScienceResource->id, null, $review->attributesToArray()); - return response()->json(); } @@ -61,17 +65,22 @@ public function update(StoreResourceReview $request, ComputerScienceResource $co 'computer_science_resource_id' => $computerScienceResource->id, ])->first(); - if (!$existingReview) { - Log::debug("User has not already posted a review"); - // TODO: Make it a json with errors instead - return back()->with('warning', 'You need to have a review posted before editing one.'); + if (! $existingReview) { + Log::warning('User has not posted a review, yet is trying to edit theirs', [ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + ]); + + return response()->json([], 400); } - Log::debug("Updating resource review: " . json_encode($validatedData)); + Log::debug('Updating resource review', [ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + 'review_data' => $validatedData, + ]); // Update the existing review - $oldAttributes = $existingReview->attributesToArray(); // Save old attributes - $existingReview->update([ 'title' => $validatedData['title'], 'description' => $validatedData['description'], @@ -85,13 +94,6 @@ public function update(StoreResourceReview $request, ComputerScienceResource $co 'cons' => $validatedData['cons'], ]); - // Dispatch event with old and new data - ResourceReviewProcessed::dispatch( - $computerScienceResource->id, - $oldAttributes, - $existingReview->attributesToArray() - ); - return response()->json(); } } diff --git a/app/Http/Controllers/TagDiffViewer.vue b/app/Http/Controllers/TagDiffViewer.vue new file mode 100644 index 00000000..3a5bd4f3 --- /dev/null +++ b/app/Http/Controllers/TagDiffViewer.vue @@ -0,0 +1,62 @@ + + + + + + + - + {{ formatValue(tag) }} + + + + + + + {{ formatValue(tag) }} + + + + diff --git a/app/Http/Controllers/TagFrequencyController.php b/app/Http/Controllers/TagFrequencyController.php index e3df355c..08691dba 100644 --- a/app/Http/Controllers/TagFrequencyController.php +++ b/app/Http/Controllers/TagFrequencyController.php @@ -3,14 +3,12 @@ namespace App\Http\Controllers; use App\Models\TagFrequency; -use Illuminate\Http\Request; class TagFrequencyController extends Controller { - public function search(string $query = "") + public function search(string $query = '') { - if (strlen($query) > 50) - { + if (strlen($query) > 50) { return response()->json(['message' => 'Query too long.'], 422); } @@ -20,7 +18,7 @@ public function search(string $query = "") ->get(); return response()->json([ - 'tags' => $prefixed_tags + 'tags' => $prefixed_tags, ]); } } diff --git a/app/Http/Controllers/UpvoteController.php b/app/Http/Controllers/UpvoteController.php index 2dc0c9f3..81be336b 100644 --- a/app/Http/Controllers/UpvoteController.php +++ b/app/Http/Controllers/UpvoteController.php @@ -2,18 +2,15 @@ namespace App\Http\Controllers; -use App\Models\UpvoteSummary; use App\Services\ModelResolverService; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\Log; use Illuminate\Validation\Rule; class UpvoteController extends Controller { protected $modelResolver; - function __construct(ModelResolverService $modelResolver) + public function __construct(ModelResolverService $modelResolver) { $this->modelResolver = $modelResolver; } @@ -28,13 +25,13 @@ public function upvote($typeKey, $id) 'type_key' => $typeKey, ], [ - 'type_key' => ['required', Rule::in(config('upvotes.upvotable_keys'))] + 'type_key' => ['required', Rule::in(config('upvotes.upvotable_keys'))], ] - )->validate(); + )->validate(); $model = $this->modelResolver->resolve($typeKey, $id); - if (!$model) { + if (! $model) { return response()->json(['message' => 'Model not found'], 404); } @@ -43,7 +40,7 @@ public function upvote($typeKey, $id) return response()->json([ 'userVote' => $result['userVote'], - 'changeFromVote' => $result['changeFromVote'] + 'changeFromVote' => $result['changeFromVote'], ]); } @@ -57,13 +54,13 @@ public function downvote($typeKey, $id) 'type_key' => $typeKey, ], [ - 'type_key' => ['required', Rule::in(config('upvotes.upvotable_keys'))] + 'type_key' => ['required', Rule::in(config('upvotes.upvotable_keys'))], ] - )->validate(); + )->validate(); $model = $this->modelResolver->resolve($typeKey, $id); - if (!$model) { + if (! $model) { return response()->json(['message' => 'Model not found'], 404); } @@ -72,7 +69,7 @@ public function downvote($typeKey, $id) return response()->json([ 'userVote' => $result['userVote'], - 'changeFromVote' => $result['changeFromVote'] + 'changeFromVote' => $result['changeFromVote'], ]); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index fbe9cdd6..b0395db1 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -39,12 +39,11 @@ public function share(Request $request): array 'flash' => [ 'success' => $request->session()->get('success'), 'warning' => $request->session()->get('warning'), - 'error' => $request->session()->get('error'), ], 'config' => [ 'COMMENT_MAX_DEPTH' => config('comment.max_depth'), 'COMMENT_PAGINATION_LIMIT' => config('comment.pagination_limit'), - ] + ], ]; } } diff --git a/app/Http/Requests/Comment/StoreCommentRequest.php b/app/Http/Requests/Comment/StoreCommentRequest.php index e27e4502..57ba2c47 100644 --- a/app/Http/Requests/Comment/StoreCommentRequest.php +++ b/app/Http/Requests/Comment/StoreCommentRequest.php @@ -33,14 +33,14 @@ public function authorize(): bool public function rules(): array { return [ - "commentable_id" => ['required', 'integer'], - "commentable_key" => [ + 'commentable_id' => ['required', 'integer'], + 'commentable_key' => [ 'required', 'string', Rule::in(config('comment.commentable_keys')), ], - "content" => ["required", "string", "max:4000"], - "parent_comment_id" => ["nullable", "exists:App\Models\Comment,id"] + 'content' => ['required', 'string', 'max:4000'], + 'parent_comment_id' => ['nullable', "exists:App\Models\Comment,id"], ]; } } diff --git a/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php b/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php index eecb12b1..14640773 100644 --- a/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php +++ b/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php @@ -2,14 +2,12 @@ namespace App\Http\Requests\ComputerScienceResource; -use App\Http\Requests\Shared\ComputerScienceResourceRequest; -use Illuminate\Support\Facades\Auth; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\Rule; class StoreResourceRequest extends FormRequest { - use ComputerScienceResourceRequest; - /** * Determine if the user is authorized to make this request. */ @@ -25,6 +23,23 @@ public function authorize(): bool */ public function rules(): array { - return $this->baseResourceRules(); + return [ + 'name' => ['required', 'string', 'max:100'], + 'description' => ['required', 'string', 'max:10000'], + 'platforms' => ['required', 'array', 'min:1'], + 'platforms.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], + 'page_url' => ['required', 'string', 'url:http,https', 'max:255'], + 'difficulty' => ['required', 'string', Rule::in(config('computerScienceResource.difficulties'))], + 'pricing' => ['required', 'string', Rule::in(config('computerScienceResource.pricings'))], + 'topic_tags' => ['required', 'array', 'min:3'], + 'topic_tags.*' => ['required', 'distinct', 'string', 'max:50'], + + // Optional, can just be omitted + 'image_file' => ['nullable', 'image', 'max:400'], // 400 kiloBytes + 'general_tags' => ['array'], + 'general_tags.*' => ['required', 'distinct', 'string', 'max:50'], + 'programming_language_tags' => ['array'], + 'programming_language_tags.*' => ['required', 'distinct', 'string', 'max:50'], + ]; } } diff --git a/app/Http/Requests/ResourceEdit/StoreResourceEdit.php b/app/Http/Requests/ResourceEdit/StoreResourceEdit.php index 517654a8..22bc4558 100644 --- a/app/Http/Requests/ResourceEdit/StoreResourceEdit.php +++ b/app/Http/Requests/ResourceEdit/StoreResourceEdit.php @@ -2,13 +2,12 @@ namespace App\Http\Requests\ResourceEdit; -use App\Http\Requests\Shared\ComputerScienceResourceRequest; -use Illuminate\Support\Facades\Auth; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Support\Facades\Auth; +use Illuminate\Validation\Rule; class StoreResourceEdit extends FormRequest { - use ComputerScienceResourceRequest; /** * Determine if the user is authorized to make this request. */ @@ -24,9 +23,25 @@ public function authorize(): bool */ public function rules(): array { - return array_merge($this->baseResourceRules(), [ + return [ 'edit_title' => ['required', 'string', 'max:100'], 'edit_description' => ['required', 'string', 'max:10000'], - ]); + 'proposed_changes' => ['sometimes', 'array'], + + 'proposed_changes.name' => ['nullable', 'string', 'max:100'], + 'proposed_changes.description' => ['nullable', 'string', 'max:10000'], + 'proposed_changes.platforms' => ['nullable', 'array'], + 'proposed_changes.platforms.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], + 'proposed_changes.page_url' => ['nullable', 'string', 'url:http,https', 'max:255'], + 'proposed_changes.difficulty' => ['nullable', 'string', Rule::in(config('computerScienceResource.difficulties'))], + 'proposed_changes.pricing' => ['nullable', 'string', Rule::in(config('computerScienceResource.pricings'))], + 'proposed_changes.topic_tags' => ['nullable', 'array', 'min:3'], + 'proposed_changes.topic_tags.*' => ['required', 'distinct', 'string', 'max:50'], + 'proposed_changes.image_file' => ['nullable', 'image', 'max:400'], // 400 kilobytes + 'proposed_changes.general_tags' => ['nullable', 'array'], + 'proposed_changes.general_tags.*' => ['required', 'distinct', 'string', 'max:50'], + 'proposed_changes.programming_language_tags' => ['nullable', 'array'], + 'proposed_changes.programming_language_tags.*' => ['required', 'distinct', 'string', 'max:50'], + ]; } } diff --git a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php index 188325eb..e69de29b 100644 --- a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php +++ b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php @@ -1,32 +0,0 @@ - ['required', 'string', 'max:100'], - 'description' => ['required', 'string', 'max:10000'], - 'platforms' => ['required', 'array', 'min:1'], - 'platforms.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], - 'page_url' => ['required', 'string', 'url:http,https', 'max:255'], - 'image_url' => ['nullable', 'string', 'url:http,https', 'max:255'], - 'difficulty' => ['required', 'string', Rule::in(config('computerScienceResource.difficulties'))], - 'pricing' => ['required', 'string', Rule::in(config('computerScienceResource.pricings'))], - - 'topic_tags' => ['required', 'array', 'min:3'], - 'topic_tags.*' => ['required', 'distinct', 'string', 'max:50'], - - // Optional, can just be omitted - 'general_tags' => ['array'], - 'general_tags.*' => ['required', 'distinct', 'string', 'max:50'], - 'programming_language_tags' => ['array'], - 'programming_language_tags.*' => ['required', 'distinct', 'string', 'max:50'], - ]; - } - -} diff --git a/app/Http/Resources/ComputerScienceResourceResource.php b/app/Http/Resources/ComputerScienceResourceResource.php index c347f3f3..871cf5bd 100644 --- a/app/Http/Resources/ComputerScienceResourceResource.php +++ b/app/Http/Resources/ComputerScienceResourceResource.php @@ -18,13 +18,13 @@ public function toArray(Request $request): array 'name' => $this->name, 'description' => $this->description, 'page_url' => $this->page_url, - 'image_url' => $this->image_url, + 'image_path' => $this->image_path, 'platforms' => $this->platforms, 'difficulty' => $this->difficulty, 'pricing' => $this->pricing, 'topic_tags' => $this->topic_tags, 'programming_language_tags' => $this->programming_language_tags, 'general_tags' => $this->general_tags, - ]; + ]; } } diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index a6e8fe74..d51a712e 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -23,5 +23,4 @@ public function toArray(Request $request): array 'profile_photo_url' => $this->profile_photo_url, ]; } - } diff --git a/app/Listeners/ModifyTagFrequency.php b/app/Listeners/ModifyTagFrequency.php index 52ca429d..2569580a 100644 --- a/app/Listeners/ModifyTagFrequency.php +++ b/app/Listeners/ModifyTagFrequency.php @@ -3,9 +3,6 @@ namespace App\Listeners; use App\Events\TagFrequencyChanged; -use App\Models\TagFrequency; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\DB; class ModifyTagFrequency @@ -26,7 +23,7 @@ public function handle(TagFrequencyChanged $event): void $old = $event->oldTags ?? []; $new = $event->newTags ?? []; - // 1. Build diffs for every tag + // Build diffs for every tag $diffs = []; foreach (array_unique(array_merge(array_keys($old), array_keys($new))) as $tag) { $diff = ($new[$tag] ?? 0) - ($old[$tag] ?? 0); @@ -39,7 +36,7 @@ public function handle(TagFrequencyChanged $event): void return; } - // 2. One upsert: increment (or decrement) existing, insert new + // One upsert: increment (or decrement) existing, insert new $upserts = []; foreach ($diffs as $tag => $count) { $upserts[] = [ @@ -56,7 +53,7 @@ public function handle(TagFrequencyChanged $event): void ] ); - // 3. Clean out any zero-or-negative counts + // Clean out any zero-or-negative counts DB::table('tag_frequencies') ->where('count', '<=', 0) ->delete(); diff --git a/app/Listeners/UpdateCommentsCount.php b/app/Listeners/UpdateCommentsCount.php deleted file mode 100644 index a8a768d9..00000000 --- a/app/Listeners/UpdateCommentsCount.php +++ /dev/null @@ -1,40 +0,0 @@ - $event->commentable_type, - 'commentable_id' => $event->commentable_id - ] - ); - - // Add 1 - $commentsCount->count = $commentsCount->count + 1; - - $commentsCount->save(); - } -} diff --git a/app/Listeners/UpdateResourceReviewSummary.php b/app/Listeners/UpdateResourceReviewSummary.php index 74a973fc..3b9e07a3 100644 --- a/app/Listeners/UpdateResourceReviewSummary.php +++ b/app/Listeners/UpdateResourceReviewSummary.php @@ -5,8 +5,6 @@ use App\Events\ResourceReviewProcessed; use App\Models\ResourceReviewSummary; use Illuminate\Support\Facades\Log; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Queue\InteractsWithQueue; class UpdateResourceReviewSummary { @@ -23,17 +21,25 @@ public function __construct() */ public function handle(ResourceReviewProcessed $event): void { - Log::debug("Handling ResourceReviewProcessed: " . json_encode($event)); + Log::debug('Handling ResourceReviewProcessed', [ + 'resource_id' => $event->resource, + 'old_review' => $event->oldReview, + 'new_review' => $event->newReview, + ]); if ($event->oldReview == null && $event->newReview == null) { - Log::critical("Update Resource Review Summary Listener reached impossible condition: null oldReview and null newReview"); + Log::critical('Update Resource Review Summary Listener reached impossible condition', [ + 'resource_id' => $event->resource, + 'error' => 'Both oldReview and newReview are null', + ]); + return; } - + $review_summary = ResourceReviewSummary::firstOrNew( ['computer_science_resource_id' => $event->resource], ); - + $fields = [ 'community', 'teaching_clarity', @@ -42,18 +48,18 @@ public function handle(ResourceReviewProcessed $event): void 'user_friendliness', 'updates', ]; - + foreach ($fields as $field) { $old = $event->oldReview[$field] ?? 0; $new = $event->newReview[$field] ?? 0; $review_summary->$field += ($new - $old); } - + if ($event->oldReview === null) { // It's a new review, so increase the count $review_summary->review_count += 1; } - + $review_summary->save(); } } diff --git a/app/Listeners/UpdateUpvoteSummary.php b/app/Listeners/UpdateUpvoteSummary.php index 0e92c9b1..beefee9a 100644 --- a/app/Listeners/UpdateUpvoteSummary.php +++ b/app/Listeners/UpdateUpvoteSummary.php @@ -5,8 +5,6 @@ use App\Events\UpvoteProcessed; use App\Models\UpvoteSummary; use Illuminate\Support\Facades\Log; -use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Queue\InteractsWithQueue; class UpdateUpvoteSummary { @@ -20,33 +18,32 @@ public function __construct() /** * Handle the event. - */ + */ public function handle(UpvoteProcessed $event): void { - Log::debug("Handling UpvoteProcessed: " . json_encode($event)); + Log::debug('Handling UpvoteProcessed', [ + 'upvotable_id' => $event->id, + 'upvotable_type' => $event->type, + 'previous_value' => $event->previousValue, + 'new_value' => $event->newValue, + ]); $summary = UpvoteSummary::firstOrNew([ 'upvotable_id' => $event->id, - 'upvotable_type' => $event->type + 'upvotable_type' => $event->type, ]); // Remove the past value - if ($event->previousValue > 0) - { + if ($event->previousValue > 0) { $summary->upvotes -= $event->previousValue; - } - elseif ($event->previousValue < 0) - { + } elseif ($event->previousValue < 0) { $summary->downvotes -= abs($event->previousValue); } // Add the new value - if ($event->newValue > 0) - { + if ($event->newValue > 0) { $summary->upvotes += $event->newValue; - } - elseif ($event->newValue < 0) - { + } elseif ($event->newValue < 0) { $summary->downvotes += abs($event->newValue); } diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 2c6c8c0c..990957de 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -2,18 +2,26 @@ namespace App\Models; +use App\Observers\CommentObserver; use App\Traits\HasVotes; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes; +#[ObservedBy([CommentObserver::class])] class Comment extends Model { + use CascadesDeletes; + /** @use HasFactory<\Database\Factories\CommentFactory> */ use HasFactory; use HasVotes; + protected $cascadeDeletes = ['votes', 'upvoteSummary']; + protected $with = ['votes', 'upvoteSummary']; protected $appends = ['vote_score', 'user_vote']; diff --git a/app/Models/CommentsCount.php b/app/Models/CommentsCount.php index 32d3f5e7..26194f19 100644 --- a/app/Models/CommentsCount.php +++ b/app/Models/CommentsCount.php @@ -6,6 +6,5 @@ class CommentsCount extends Model { - // protected $fillable = ['commentable_type', 'commentable_id']; } diff --git a/app/Models/ComputerScienceResource.php b/app/Models/ComputerScienceResource.php index 8cc750ab..771714bd 100644 --- a/app/Models/ComputerScienceResource.php +++ b/app/Models/ComputerScienceResource.php @@ -2,36 +2,50 @@ namespace App\Models; +use App\Observers\ComputerScienceResourceObserver; use App\Traits\HasComments; use App\Traits\HasVotes; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Casts\Attribute; -use Spatie\Tags\HasTags; -use Illuminate\Support\Facades\Auth; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Facades\Storage; +use Spatie\Tags\HasTags; +#[ObservedBy([ComputerScienceResourceObserver::class])] class ComputerScienceResource extends Model { + use HasComments; + /** @use HasFactory<\Database\Factories\ComputerScienceResourceFactory> */ use HasFactory; use HasTags; - use HasComments; use HasVotes; - protected $table = "computer_science_resources"; + protected $table = 'computer_science_resources'; protected $guarded = []; - protected $appends = ['topic_tags', 'programming_language_tags', 'general_tags', 'vote_score', 'user_vote', 'comments_count']; + protected $appends = ['topic_tags', 'programming_language_tags', 'general_tags', 'vote_score', 'user_vote', 'comments_count', 'image_url']; public function user(): BelongsTo { return $this->belongsTo(User::class); } + /** + * Attribute to get the image_url + */ + protected function imageUrl(): Attribute + { + return Attribute::make( + get: fn () => $this->image_path ? Storage::url($this->image_path) : null, + ); + } + /** * Get the review summary relationship. */ @@ -55,59 +69,52 @@ public function edits(): HasMany /** * Attribute to get and set platforms as an array - * - * @return Attribute */ protected function platforms(): Attribute { return Attribute::make( - get: fn($value) => explode(',', $value), - set: fn($value) => implode(',', $value) + get: fn ($value) => explode(',', $value), + set: fn ($value) => implode(',', $value) ); } /** * Accessor to get topic tags. - * - * @return Attribute */ protected function topicTags(): Attribute { return Attribute::make( - get: fn() => $this->tagsWithType('topics')->pluck('name')->toArray(), - set: fn(array $value) => $this->syncTagsWithType($value, 'topics') + get: fn () => $this->tagsWithType('topics')->pluck('name')->toArray(), + set: fn (array $value) => $this->syncTagsWithType($value, 'topics') ); } /** * Accessor to get programming language tags. - * - * @return Attribute */ protected function programmingLanguageTags(): Attribute { return Attribute::make( - get: fn() => $this->tagsWithType('programming_languages')->pluck('name')->toArray(), - set: fn(array $value) => $this->syncTagsWithType($value, 'programming_languages') + get: fn () => $this->tagsWithType('programming_languages')->pluck('name')->toArray(), + set: fn (array $value) => $this->syncTagsWithType($value, 'programming_languages') ); } /** * Accessor to get general tags. - * - * @return Attribute */ protected function generalTags(): Attribute { return Attribute::make( - get: fn() => $this->tagsWithType('general_tags')->pluck('name')->toArray(), - set: fn(array $value) => $this->syncTagsWithType($value, 'general_tags') + get: fn () => $this->tagsWithType('general_tags')->pluck('name')->toArray(), + set: fn (array $value) => $this->syncTagsWithType($value, 'general_tags') ); } public function tagCounter(): array { $tag_collection = collect([$this->topic_tags, $this->programming_language_tags, $this->general_tags]); + return $tag_collection->flatten()->countBy()->toArray(); } } diff --git a/app/Models/ResourceEdits.php b/app/Models/ResourceEdits.php index df99a59b..1766c2fd 100644 --- a/app/Models/ResourceEdits.php +++ b/app/Models/ResourceEdits.php @@ -2,31 +2,39 @@ namespace App\Models; +use App\Observers\ResourceEditsObserver; use App\Services\ResourceEditsService; use App\Traits\HasComments; use App\Traits\HasVotes; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Storage; +use ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes; +#[ObservedBy([ResourceEditsObserver::class])] class ResourceEdits extends Model { + use CascadesDeletes; + + use HasComments; /** @use HasFactory<\Database\Factories\ResourceEditsFactory> */ use HasFactory; - use HasComments; use HasVotes; + protected $cascadeDeletes = ['votes', 'upvoteSummary', 'comments', 'commentsCountRelationship']; + protected $guarded = []; - protected $with = ['votes','upvoteSummary', 'commentsCountRelationship', 'resource']; + protected $with = ['votes', 'upvoteSummary', 'commentsCountRelationship', 'resource']; protected $appends = ['user_vote', 'vote_score', 'comments_count', 'can_merge_edits']; protected $casts = [ - 'topic_tags' => 'array', - 'programming_language_tags' => 'array', - 'general_tags' => 'array', + 'proposed_changes' => 'array', ]; public function resource(): BelongsTo @@ -34,28 +42,36 @@ public function resource(): BelongsTo return $this->belongsTo(ComputerScienceResource::class, 'computer_science_resource_id', 'id'); } - /** - * Attribute to get and set platforms as an array - * - * @return Attribute - */ - protected function platforms(): Attribute + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + protected function proposedChanges(): Attribute { return Attribute::make( - get: fn($value) => explode(',', $value), - set: fn($value) => implode(',', $value) + get: function ($value) { + $changes = json_decode($value, true); + + if (array_key_exists('image_path', $changes)) { + $changes['image_url'] = null; + if ($changes['image_path']) { + $changes['image_url'] = Storage::url($changes['image_path']); + } + } + + return $changes; + }, ); } /** * Attribute to know if the edit can be merged - * - * @return Attribute */ protected function canMergeEdits(): Attribute { return Attribute::make( - get: fn () => app(ResourceEditsService::class)->canMergeEdits($this), + get: fn () => app(ResourceEditsService::class)->canMergeEdits($this) && Auth::id() === $this->user_id, ); } -} \ No newline at end of file +} diff --git a/app/Models/ResourceReview.php b/app/Models/ResourceReview.php index 79d89ac4..2b5b73a9 100644 --- a/app/Models/ResourceReview.php +++ b/app/Models/ResourceReview.php @@ -2,18 +2,23 @@ namespace App\Models; +use App\Observers\ResourceReviewObserver; use App\Traits\HasComments; use App\Traits\HasVotes; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +#[ObservedBy([ResourceReviewObserver::class])] class ResourceReview extends Model { + use HasComments; + /** @use HasFactory<\Database\Factories\ResourceReviewFactory> */ use HasFactory; use HasVotes; - use HasComments; protected $guarded = []; @@ -26,10 +31,13 @@ class ResourceReview extends Model 'cons' => 'array', ]; + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + /** * Get the average review score. - * - * @return Attribute */ protected function averageScore(): Attribute { diff --git a/app/Models/ResourceReviewSummary.php b/app/Models/ResourceReviewSummary.php index 08118068..ecff7592 100644 --- a/app/Models/ResourceReviewSummary.php +++ b/app/Models/ResourceReviewSummary.php @@ -4,13 +4,14 @@ use App\Traits\HasComments; use App\Traits\HasVotes; -use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; class ResourceReviewSummary extends Model { - use HasVotes; use HasComments; + use HasVotes; + + public $timestamps = false; protected $fillable = ['computer_science_resource_id']; diff --git a/app/Models/Upvote.php b/app/Models/Upvote.php index 57d4b457..6a95c3c2 100644 --- a/app/Models/Upvote.php +++ b/app/Models/Upvote.php @@ -10,5 +10,7 @@ class Upvote extends Model /** @use HasFactory<\Database\Factories\UpvoteFactory> */ use HasFactory; + public $timestamps = false; + protected $fillable = ['value', 'user_id']; } diff --git a/app/Models/UpvoteSummary.php b/app/Models/UpvoteSummary.php index ca3cbe83..48c308e1 100644 --- a/app/Models/UpvoteSummary.php +++ b/app/Models/UpvoteSummary.php @@ -3,27 +3,28 @@ namespace App\Models; use Illuminate\Database\Eloquent\Casts\Attribute; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; class UpvoteSummary extends Model { use HasFactory; public $timestamps = false; + protected $fillable = ['upvotable_id', 'upvotable_type']; public function voteScore(): Attribute { return Attribute::make( - get: fn() => $this->upvotes - $this->downvotes + get: fn () => $this->upvotes - $this->downvotes ); } protected function votesCount(): Attribute { return Attribute::make( - get: fn() => $this->upvotes + $this->downvotes, + get: fn () => $this->upvotes + $this->downvotes, ); } } diff --git a/app/Models/User.php b/app/Models/User.php index 2b475cce..7482db6e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Traits\HasCustomProfilePhoto; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -16,10 +17,10 @@ class User extends Authenticatable { use HasApiTokens; use HasConnectedAccounts; - use HasFactory; - use HasProfilePhoto { - HasProfilePhoto::profilePhotoUrl as getPhotoUrl; + use HasCustomProfilePhoto, HasProfilePhoto { + HasCustomProfilePhoto::profilePhotoUrl as getPhotoUrl; } + use HasFactory; use Notifiable; use SetsProfilePhotoFromUrl; use TwoFactorAuthenticatable; diff --git a/app/Observers/CommentObserver.php b/app/Observers/CommentObserver.php new file mode 100644 index 00000000..29bcf29d --- /dev/null +++ b/app/Observers/CommentObserver.php @@ -0,0 +1,87 @@ + $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + ]); + + // Create the upvotes summary + UpvoteSummary::create([ + 'upvotable_id' => $comment->id, + 'upvotable_type' => Comment::class, + ]); + + $commentsCount = CommentsCount::firstOrNew( + [ + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + ] + ); + + // Add 1 to the count (handle null case) + $commentsCount->count = ($commentsCount->count ?? 0) + 1; + + $commentsCount->save(); + } + + /** + * Handle the Comment "updated" event. + */ + public function updated(Comment $comment): void + { + // + } + + /** + * Handle the Comment "deleted" event. + */ + public function deleted(Comment $comment): void + { + Log::debug('Handling comment deleted', [ + 'comment_id' => $comment->id, + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + ]); + + // Decrease the comment count + $commentsCount = CommentsCount::where([ + 'commentable_type' => $comment->commentable_type, + 'commentable_id' => $comment->commentable_id, + ])->first(); + + if ($commentsCount) { + $commentsCount->count = max(0, ($commentsCount->count ?? 1) - 1); + $commentsCount->save(); + } + } + + /** + * Handle the Comment "restored" event. + */ + public function restored(Comment $comment): void + { + // + } + + /** + * Handle the Comment "force deleted" event. + */ + public function forceDeleted(Comment $comment): void + { + // + } +} diff --git a/app/Observers/ComputerScienceResourceObserver.php b/app/Observers/ComputerScienceResourceObserver.php new file mode 100644 index 00000000..919a5f50 --- /dev/null +++ b/app/Observers/ComputerScienceResourceObserver.php @@ -0,0 +1,56 @@ + $computerScienceResource->id, + 'upvotable_type' => ComputerScienceResource::class, + ]); + + // TagFrequencyChanged is in store ComputerScienceResource controller + } + + /** + * Handle the ComputerScienceResource "updated" event. + */ + public function updated(ComputerScienceResource $computerScienceResource): void + { + // + } + + /** + * Handle the ComputerScienceResource "deleted" event. + */ + public function deleted(ComputerScienceResource $computerScienceResource): void + { + // + } + + /** + * Handle the ComputerScienceResource "restored" event. + */ + public function restored(ComputerScienceResource $computerScienceResource): void + { + // + } + + /** + * Handle the ComputerScienceResource "force deleted" event. + */ + public function forceDeleted(ComputerScienceResource $computerScienceResource): void + { + // + } +} diff --git a/app/Observers/ResourceEditsObserver.php b/app/Observers/ResourceEditsObserver.php new file mode 100644 index 00000000..998d5685 --- /dev/null +++ b/app/Observers/ResourceEditsObserver.php @@ -0,0 +1,53 @@ + $resourceEdits->id, + 'upvotable_type' => ResourceEdits::class, + ]); + } + + /** + * Handle the ResourceEdits "updated" event. + */ + public function updated(ResourceEdits $resourceEdits): void + { + // + } + + /** + * Handle the ResourceEdits "deleted" event. + */ + public function deleted(ResourceEdits $resourceEdits): void + { + // + } + + /** + * Handle the ResourceEdits "restored" event. + */ + public function restored(ResourceEdits $resourceEdits): void + { + // + } + + /** + * Handle the ResourceEdits "force deleted" event. + */ + public function forceDeleted(ResourceEdits $resourceEdits): void + { + // + } +} diff --git a/app/Observers/ResourceReviewObserver.php b/app/Observers/ResourceReviewObserver.php new file mode 100644 index 00000000..025bc51f --- /dev/null +++ b/app/Observers/ResourceReviewObserver.php @@ -0,0 +1,64 @@ + $resourceReview->id, + 'upvotable_type' => ResourceReview::class, + ]); + + ResourceReviewProcessed::dispatch( + $resourceReview->computer_science_resource_id, + null, + $resourceReview->attributesToArray() + ); + } + + /** + * Handle the ResourceReview "updated" event. + */ + public function updated(ResourceReview $resourceReview): void + { + ResourceReviewProcessed::dispatch( + $resourceReview->computer_science_resource_id, + $resourceReview->getOriginal(), + $resourceReview->attributesToArray() + ); + } + + /** + * Handle the ResourceReview "deleted" event. + */ + public function deleted(ResourceReview $resourceReview): void + { + // + } + + /** + * Handle the ResourceReview "restored" event. + */ + public function restored(ResourceReview $resourceReview): void + { + // + } + + /** + * Handle the ResourceReview "force deleted" event. + */ + public function forceDeleted(ResourceReview $resourceReview): void + { + // + } +} diff --git a/app/Policies/ComputerScienceResourcePolicy.php b/app/Policies/ComputerScienceResourcePolicy.php index fd4fd46f..843d58e8 100644 --- a/app/Policies/ComputerScienceResourcePolicy.php +++ b/app/Policies/ComputerScienceResourcePolicy.php @@ -4,7 +4,6 @@ use App\Models\ComputerScienceResource; use App\Models\User; -use Illuminate\Auth\Access\Response; class ComputerScienceResourcePolicy { diff --git a/app/Policies/ResourceEditsPolicy.php b/app/Policies/ResourceEditsPolicy.php new file mode 100644 index 00000000..881e4fd7 --- /dev/null +++ b/app/Policies/ResourceEditsPolicy.php @@ -0,0 +1,70 @@ +id == $resourceEdits->user_id; + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return false; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ResourceEdits $resourceEdits): bool + { + return false; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ResourceEdits $resourceEdits): bool + { + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, ResourceEdits $resourceEdits): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, ResourceEdits $resourceEdits): bool + { + return false; + } +} diff --git a/app/Providers/ModelResolverServiceProvider.php b/app/Providers/ModelResolverServiceProvider.php index f4429d2a..291d8832 100644 --- a/app/Providers/ModelResolverServiceProvider.php +++ b/app/Providers/ModelResolverServiceProvider.php @@ -2,8 +2,8 @@ namespace App\Providers; -use Illuminate\Support\ServiceProvider; use App\Services\ModelResolverService; +use Illuminate\Support\ServiceProvider; class ModelResolverServiceProvider extends ServiceProvider { @@ -13,7 +13,7 @@ class ModelResolverServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(ModelResolverService::class, function ($app) { - return new ModelResolverService(); + return new ModelResolverService; }); } diff --git a/app/Services/CommentService.php b/app/Services/CommentService.php index 36938f93..9b3432c3 100644 --- a/app/Services/CommentService.php +++ b/app/Services/CommentService.php @@ -4,8 +4,8 @@ use App\Http\Resources\CommentResource; use App\Http\Resources\UserResource; -use App\Services\SortingManagers\GeneralVotesSortingManager; use App\Models\Comment; +use App\Services\SortingManagers\GeneralVotesSortingManager; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; @@ -15,7 +15,7 @@ class CommentService { protected $modelResolver; - function __construct(ModelResolverService $resolver) + public function __construct(ModelResolverService $resolver) { $this->modelResolver = $resolver; } @@ -23,15 +23,11 @@ function __construct(ModelResolverService $resolver) /** * Get paginated comments with custom logic. * - * @param string $commentableType - * @param int $commentableId - * @param int $index - * @return array + * @param string $commentableType */ public function getPaginatedComments(string $commentableKey, int $commentableId, int $index, int $paginationLimit = -1, string $sortBy = 'top'): array { - if ($paginationLimit == -1) - { + if ($paginationLimit == -1) { $paginationLimit = config('comment.default_pagination_limit'); } @@ -39,14 +35,22 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, 'index' => $index, 'commentable_key' => $commentableKey, 'pagination_limit' => $paginationLimit, + 'sort_by' => $sortBy, ], [ 'index' => ['required', 'integer', 'min:0'], 'commentable_key' => ['required', Rule::in(config('comment.commentable_keys'))], - 'pagination_limit' => ['required', 'integer', 'max:' . config('comment.pagination_limit')], + 'pagination_limit' => ['required', 'integer', 'max:'.config('comment.pagination_limit')], + 'sort_by' => ['required', 'string'], ]); $commentableType = $this->modelResolver->getModelClass($commentableKey); - Log::debug("Request is, commentable_type: {$commentableType}. id: {$commentableId}. index: {$index}"); + Log::debug('Getting paginated comments', [ + 'commentable_type' => $commentableType, + 'commentable_id' => $commentableId, + 'index' => $index, + 'sort_by' => $sortBy, + 'pagination_limit' => $paginationLimit, + ]); // Get the root comments: $query = Comment::where([ @@ -59,7 +63,11 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, $query = app(GeneralVotesSortingManager::class)->applySort($query, $sortBy, Comment::class); $rootComments = $query->get(); - Log::debug("Root comments: " . json_encode($rootComments)); + Log::debug('Root comments retrieved', [ + 'count' => $rootComments->count(), + 'commentable_type' => $commentableType, + 'commentable_id' => $commentableId, + ]); // Initialize variables $currentCommentsSum = 0; @@ -73,13 +81,13 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, // Handle comments that exceed MAX when alone in a page if ($currentCommentsSum + $childrenCount > $paginationLimit) { if ($currentCommentsSum === 0) { - // Force include oversized comment if it's the first in page - Log::warning("Had to force include for oversized comment tree. Should consider increasing the max commentx in config or lowering the replies size limit."); + // Force include oversized comment if ($currentIndex === $index) { $resultingPaginatedComments[] = $comment; $currentCommentsSum += $childrenCount; } $currentIndex++; + continue; } @@ -93,7 +101,7 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, break; } // Only add comments for the desired index - else if ($currentIndex === $index) { + elseif ($currentIndex === $index) { $resultingPaginatedComments[] = $comment; } $currentCommentsSum += $childrenCount; @@ -131,7 +139,13 @@ public function getPaginatedComments(string $commentableKey, int $commentableId, } } - Log::debug("Returned comments: " . json_encode($flattenedComments)); + Log::debug('Returning paginated comments', [ + 'comments_count' => $flattenedComments->count(), + 'users_count' => $users->count(), + 'has_more_comments' => $hasMoreComments, + 'current_index' => $index, + ]); + return [ 'comments' => $flattenedComments, 'users' => $users->values(), diff --git a/app/Services/ComputerScienceResourceFilter.php b/app/Services/ComputerScienceResourceFilter.php index f2cec7de..2670d6e3 100644 --- a/app/Services/ComputerScienceResourceFilter.php +++ b/app/Services/ComputerScienceResourceFilter.php @@ -1,13 +1,15 @@ ['nullable', 'string', 'max:1000'], 'platforms' => ['nullable', 'array'], 'platforms.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], - 'difficulty' => ['nullable', 'string', Rule::in(config('computerScienceResource.difficulties'))], - 'pricing' => ['nullable', 'string', Rule::in(config('computerScienceResource.pricings'))], + 'difficulty' => ['nullable', 'array'], + 'difficulty.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.difficulties'))], + 'pricing' => ['nullable', 'array'], + 'pricing.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.pricings'))], 'topics' => ['nullable', 'array'], 'topics.*' => ['required', 'distinct', 'string', 'max:50'], 'general_tags' => ['nullable', 'array'], @@ -32,7 +36,9 @@ public function validate(array $request) 'programming_languages' => ['nullable', 'array'], 'programming_languages.*' => ['required', 'distinct', 'string', 'max:50'], - 'community_rating' => ['nullable', 'integer', 'between:1,4'], + // Fixed field names to match frontend + 'overall' => ['nullable', 'integer', 'between:1,4'], + 'community' => ['nullable', 'integer', 'between:1,4'], 'teaching_clarity' => ['nullable', 'integer', 'between:1,4'], 'engagement' => ['nullable', 'integer', 'between:1,4'], 'practicality' => ['nullable', 'integer', 'between:1,4'], @@ -44,8 +50,8 @@ public function validate(array $request) 'updated_from' => ['nullable', 'date'], 'updated_to' => ['nullable', 'date'], - 'sort_by' => ['nullable', 'string'], - 'reverse' => ['nullable', 'string'], + 'sort_by' => ['string'], + 'reverse' => ['string'], ]; $validator = Validator::make($request, $rules); @@ -60,17 +66,17 @@ public function applyFilters($query, array $filters) $query->with(['tags', 'votes', 'upvoteSummary', 'reviewSummary', 'commentsCountRelationship']); // Fulltext search on name - if (!empty($filters['name'])) { + if (! empty($filters['name'])) { $query->whereFullText('name', $filters['name']); } // Fulltext search on description - if (!empty($filters['description'])) { + if (! empty($filters['description'])) { $query->whereFullText('description', $filters['description']); } // Filter by platforms (array) - if (!empty($filters['platforms'])) { + if (! empty($filters['platforms'])) { $query->where(function ($q) use ($filters) { foreach ($filters['platforms'] as $platform) { $q->orWhereRaw('FIND_IN_SET(?, platforms)', [$platform]); @@ -79,27 +85,27 @@ public function applyFilters($query, array $filters) } // Filter by difficulty - if (!empty($filters['difficulty'])) { + if (! empty($filters['difficulty'])) { $query->whereIn('difficulty', (array) $filters['difficulty']); } // Filter by pricing - if (!empty($filters['pricing'])) { + if (! empty($filters['pricing'])) { $query->whereIn('pricing', (array) $filters['pricing']); } // Filter by topic tags - if (!empty($filters['topics'])) { + if (! empty($filters['topics'])) { $query->withAnyTags((array) $filters['topics'], 'topics'); } // Filter by programming languages - if (!empty($filters['programming_languages'])) { + if (! empty($filters['programming_languages'])) { $query->withAnyTags((array) $filters['programming_languages'], 'programming_languages'); } // Filter by general tags - if (!empty($filters['general_tags'])) { + if (! empty($filters['general_tags'])) { $query->withAnyTags((array) $filters['general_tags'], 'general_tags'); } @@ -115,24 +121,24 @@ public function applyFilters($query, array $filters) ]; foreach ($ratingFilters as $field) { - if (!empty($filters[$field])) { + if (! empty($filters[$field])) { $query = $this->reviewService->applyRatingFilter($query, $field, $filters[$field]); } } // Filter by Date posted - if (!empty($filters['created_from'])) { + if (! empty($filters['created_from'])) { $query->whereDate('computer_science_resources.created_at', '>=', $filters['created_from']); } - if (!empty($filters['created_to'])) { + if (! empty($filters['created_to'])) { $query->whereDate('computer_science_resources.created_at', '<=', $filters['created_to']); } // Filter by Date updated - if (!empty($filters['updated_from'])) { + if (! empty($filters['updated_from'])) { $query->whereDate('computer_science_resources.updated_at', '>=', $filters['updated_from']); } - if (!empty($filters['updated_to'])) { + if (! empty($filters['updated_to'])) { $query->whereDate('computer_science_resources.updated_at', '<=', $filters['updated_to']); } diff --git a/app/Services/DataNormalizationService.php b/app/Services/DataNormalizationService.php new file mode 100644 index 00000000..a4f6530d --- /dev/null +++ b/app/Services/DataNormalizationService.php @@ -0,0 +1,30 @@ +normalize($array1) === $this->normalize($array2); + } +} diff --git a/app/Services/ModelResolverService.php b/app/Services/ModelResolverService.php index 58b78191..f31d1f95 100644 --- a/app/Services/ModelResolverService.php +++ b/app/Services/ModelResolverService.php @@ -14,17 +14,17 @@ class ModelResolverService /** * Finds the model that exists for the given type and id - * - * @param $type, the colloquial name for the type - * @param $id, the id for the type - * + * + * @param $type, the colloquial name for the type + * @param $id, the id for the type + * * returns null if no model exists, otherwise, it will return the model */ public function resolve($type, $id) { $modelClass = $this->getModelClass($type); - - if (!$modelClass) { + + if (! $modelClass) { return null; } diff --git a/app/Services/ResourceEditsService.php b/app/Services/ResourceEditsService.php index cbe0bc72..9f28aa8e 100644 --- a/app/Services/ResourceEditsService.php +++ b/app/Services/ResourceEditsService.php @@ -16,18 +16,17 @@ class ResourceEditsService */ public function requiredVotes(int $totalVotes): int { - // Either the current votes, or the log equation - $votes = min($totalVotes, - floor(log($totalVotes, 1.25)) + 1 - ); - return max(3, $votes); // Need to be 3 votes minimum + // Take the minimum of total votes OR the logarithmic calculation + $votes = min($totalVotes, floor(log($totalVotes, 1.25)) + 1); + + // Ensure minimum of 3 votes is always required + return max(3, $votes); } /** * Handles determining if a resource edit is mergeable, by getting the upvotes for the resource edit - * */ - public function canMergeEdits(ResourceEdits $edits) : bool + public function canMergeEdits(ResourceEdits $edits): bool { if (app()->isLocal()) { return true; diff --git a/app/Services/ResourceReviewService.php b/app/Services/ResourceReviewService.php index b2e1caef..b551c4de 100644 --- a/app/Services/ResourceReviewService.php +++ b/app/Services/ResourceReviewService.php @@ -2,9 +2,9 @@ namespace App\Services; +use App\Traits\HandlesResourceReviewJoins; use Illuminate\Database\Eloquent\Builder; use InvalidArgumentException; -use App\Traits\HandlesResourceReviewJoins; class ResourceReviewService { @@ -17,7 +17,7 @@ public function applyRatingFilter(Builder $query, string $field, int $minRating) } if ($minRating < 1 || $minRating > 5) { - throw new InvalidArgumentException("Rating must be between 1 and 5."); + throw new InvalidArgumentException('Rating must be between 1 and 5.'); } $query = $this->ensureReviewSummaryJoined($query); @@ -26,4 +26,3 @@ public function applyRatingFilter(Builder $query, string $field, int $minRating) return $query->where("{$reviewTable}.{$field}_rating", '>=', $minRating); } } - diff --git a/app/Services/SortingManagers/SortingManager.php b/app/Services/SortingManagers/SortingManager.php index bfb4696b..ca45df42 100644 --- a/app/Services/SortingManagers/SortingManager.php +++ b/app/Services/SortingManagers/SortingManager.php @@ -2,8 +2,8 @@ namespace App\Services\SortingManagers; -use Illuminate\Database\Eloquent\Builder; use App\Contracts\SortStrategy; +use Illuminate\Database\Eloquent\Builder; class SortingManager { diff --git a/app/SortingStrategies/ResourceReviewsSortingStrategy.php b/app/SortingStrategies/ResourceReviewsSortingStrategy.php index a6de2620..b734ecb0 100644 --- a/app/SortingStrategies/ResourceReviewsSortingStrategy.php +++ b/app/SortingStrategies/ResourceReviewsSortingStrategy.php @@ -3,8 +3,8 @@ namespace App\SortingStrategies; use App\Contracts\SortingStrategy; -use Illuminate\Database\Eloquent\Builder; use App\Traits\HandlesResourceReviewJoins; +use Illuminate\Database\Eloquent\Builder; class ResourceReviewsSortingStrategy implements SortingStrategy { @@ -17,7 +17,7 @@ public static function supports(string $sortBy): bool public static function apply(Builder $query, string $sortBy): Builder { - $instance = new self(); + $instance = new self; $resourceTable = $query->getModel()->getTable(); $query = $instance->ensureReviewSummaryJoined($query); $reviewTable = $instance->getReviewSummaryTable(); diff --git a/app/SortingStrategies/VoteSortingStrategy.php b/app/SortingStrategies/VoteSortingStrategy.php index 35a44db4..f2ad7c7f 100644 --- a/app/SortingStrategies/VoteSortingStrategy.php +++ b/app/SortingStrategies/VoteSortingStrategy.php @@ -17,10 +17,10 @@ public static function apply(Builder $query, string $sortBy): Builder $table = $query->getModel()->getTable(); $modelClass = get_class($query->getModel()); - # Join on polymorphic relationship - $query->leftJoin('upvote_summaries', function ($join) use ($table, $modelClass) { + // Join on polymorphic relationship + $query->join('upvote_summaries', function ($join) use ($table, $modelClass) { $join->on('upvote_summaries.upvotable_id', '=', "{$table}.id") - ->where('upvote_summaries.upvotable_type', '=', $modelClass); + ->where('upvote_summaries.upvotable_type', '=', $modelClass); })->select("{$table}.*"); switch ($sortBy) { diff --git a/app/Traits/HandlesResourceReviewJoins.php b/app/Traits/HandlesResourceReviewJoins.php index 5abcb847..6fdbe4f6 100644 --- a/app/Traits/HandlesResourceReviewJoins.php +++ b/app/Traits/HandlesResourceReviewJoins.php @@ -2,14 +2,14 @@ namespace App\Traits; -use Illuminate\Database\Eloquent\Builder; use App\Models\ResourceReviewSummary; +use Illuminate\Database\Eloquent\Builder; trait HandlesResourceReviewJoins { protected function ensureReviewSummaryJoined(Builder $query): Builder { - $reviewTable = (new ResourceReviewSummary())->getTable(); + $reviewTable = (new ResourceReviewSummary)->getTable(); $joins = $query->getQuery()->joins ?? []; @@ -32,7 +32,7 @@ protected function ensureReviewSummaryJoined(Builder $query): Builder protected function getReviewSummaryTable(): string { - return (new ResourceReviewSummary())->getTable(); + return (new ResourceReviewSummary)->getTable(); } protected function getAllowedReviewFields(): array diff --git a/app/Traits/HasComments.php b/app/Traits/HasComments.php index 4afab144..38041116 100644 --- a/app/Traits/HasComments.php +++ b/app/Traits/HasComments.php @@ -2,16 +2,25 @@ namespace App\Traits; +use App\Models\Comment; use App\Models\CommentsCount; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; trait HasComments { + /** Get all of the comments for the HasComments + * + */ + public function comments(): HasMany + { + return $this->hasMany(Comment::class, 'commentable_id', 'id') + ->where('commentable_type', static::class); + } + /** * Define a relationship to the CommentsCount model. - * - * @return \Illuminate\Database\Eloquent\Relations\HasOne */ public function commentsCountRelationship(): HasOne { diff --git a/app/Traits/HasCustomProfilePhoto.php b/app/Traits/HasCustomProfilePhoto.php new file mode 100644 index 00000000..bf3c35fa --- /dev/null +++ b/app/Traits/HasCustomProfilePhoto.php @@ -0,0 +1,28 @@ +profile_photo_path, FILTER_VALIDATE_URL)) { + return $this->profile_photo_path; + } + + // Otherwise, generate a default avatar URL + $name = trim(collect(explode(' ', $this->name))->map(function ($segment) { + return mb_substr($segment, 0, 1); + })->join(' ')); + + return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=e05c00&background=fff7cc'; + }); + } +} diff --git a/app/Traits/HasVotes.php b/app/Traits/HasVotes.php index db9bd348..fb8da468 100644 --- a/app/Traits/HasVotes.php +++ b/app/Traits/HasVotes.php @@ -2,50 +2,44 @@ namespace App\Traits; -use App\Models\Upvote; use App\Events\UpvoteProcessed; +use App\Models\Upvote; use App\Models\UpvoteSummary; -use Illuminate\Support\Facades\Auth; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; +use Illuminate\Support\Facades\Auth; trait HasVotes { /** * Accessor to get the current user's vote. - * - * @return \Illuminate\Database\Eloquent\Casts\Attribute */ protected function userVote(): Attribute { return Attribute::make( - get: fn() => Auth::check() ? $this->getVoteValue(Auth::id()) : 0 + get: fn () => Auth::check() ? $this->getVoteValue(Auth::id()) : 0 ); } /** * Accessor to get vote sum. - * - * @return Attribute */ protected function voteScore(): Attribute { return Attribute::make( - get: fn() => $this->upvoteSummary ? + get: fn () => $this->upvoteSummary ? $this->upvoteSummary->vote_score : 0, ); } /** * Accessor to get vote count. - * - * @return Attribute */ protected function votesCount(): Attribute { return Attribute::make( - get: fn() => $this->upvoteSummary ? $this->upvoteSummary->votes_count : 0 + get: fn () => $this->upvoteSummary ? $this->upvoteSummary->votes_count : 0 ); } @@ -76,21 +70,23 @@ public function upvote($userId): array if ($currentVote > 0) { UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, 0); - return array( + + return [ 'model' => $this->deleteVote($userId), // The value of the user vote (-n,0,n) 'userVote' => 0, // What the change of votes of the model after the user voted 'changeFromVote' => 0 - $currentVote, - ); + ]; } UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, 1); - return array( + + return [ 'model' => $this->vote($userId, 1), 'userVote' => 1, 'changeFromVote' => 1 - $currentVote, - ); + ]; } /** @@ -104,19 +100,21 @@ public function downvote($userId): array if ($currentVote < 0) { UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, 0); - return array( + + return [ 'model' => $this->deleteVote($userId), 'userVote' => 0, 'changeFromVote' => 0 - $currentVote, - ); + ]; } UpvoteProcessed::dispatch($modelType, $modelId, $currentVote, -1); - return array( + + return [ 'model' => $this->vote($userId, -1), 'userVote' => -1, 'changeFromVote' => -1 - $currentVote, - ); + ]; } public function getChangeInVotes(): int @@ -143,9 +141,10 @@ public function unvote($userId) public function getVoteValue($userId): int { $vote = $this->votes->where('user_id', $userId)->first(); + return $vote ? $vote->value : 0; } - + /** * Vote on the model. */ diff --git a/composer.json b/composer.json index 5ebfb7ee..e30db757 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "laravel/jetstream": "^5.3", "laravel/sanctum": "^4.0", "laravel/tinker": "^2.9", + "shiftonelabs/laravel-cascade-deletes": "^2.0", "spatie/laravel-tags": "^4.9", "tightenco/ziggy": "^2.0" }, @@ -22,7 +23,7 @@ "beyondcode/laravel-query-detector": "^2.0", "fakerphp/faker": "^1.23", "laravel/pail": "^1.1", - "laravel/pint": "^1.13", + "laravel/pint": "^1.24", "laravel/sail": "^1.40", "laravel/telescope": "^5.3", "mockery/mockery": "^1.6", diff --git a/composer.lock b/composer.lock index b8fa7528..c20d24c1 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7725813741904ac1263575e1bcce49b0", + "content-hash": "a4d431d94e32b4fdec6440b52e4361cb", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4352,6 +4352,63 @@ ], "time": "2024-04-27T21:32:50+00:00" }, + { + "name": "shiftonelabs/laravel-cascade-deletes", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/shiftonelabs/laravel-cascade-deletes.git", + "reference": "bf1eeb195513b41a98bbcd0f598f601b0e006bc2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/shiftonelabs/laravel-cascade-deletes/zipball/bf1eeb195513b41a98bbcd0f598f601b0e006bc2", + "reference": "bf1eeb195513b41a98bbcd0f598f601b0e006bc2", + "shasum": "" + }, + "require": { + "illuminate/database": ">=9.0", + "illuminate/events": ">=9.0", + "php": ">=8.0.2" + }, + "require-dev": { + "mockery/mockery": "~1.3", + "phpunit/phpunit": "~9.3 || ~10.0", + "shiftonelabs/codesniffer-standard": "0.*", + "squizlabs/php_codesniffer": "3.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "ShiftOneLabs\\LaravelCascadeDeletes\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Carlo-Hickman", + "email": "patrick@shiftonelabs.com" + } + ], + "description": "Adds application level cascading deletes to Eloquent Models.", + "homepage": "https://github.com/shiftonelabs/laravel-cascade-deletes", + "keywords": [ + "cascade", + "deletes", + "eloquent", + "laravel", + "lumen", + "model" + ], + "support": { + "issues": "https://github.com/shiftonelabs/laravel-cascade-deletes/issues", + "source": "https://github.com/shiftonelabs/laravel-cascade-deletes" + }, + "time": "2024-12-14T18:36:46+00:00" + }, { "name": "spatie/eloquent-sortable", "version": "4.4.1", @@ -7917,16 +7974,16 @@ }, { "name": "laravel/pint", - "version": "v1.20.0", + "version": "v1.24.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b" + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/53072e8ea22213a7ed168a8a15b96fbb8b82d44b", - "reference": "53072e8ea22213a7ed168a8a15b96fbb8b82d44b", + "url": "https://api.github.com/repos/laravel/pint/zipball/0345f3b05f136801af8c339f9d16ef29e6b4df8a", + "reference": "0345f3b05f136801af8c339f9d16ef29e6b4df8a", "shasum": "" }, "require": { @@ -7934,15 +7991,15 @@ "ext-mbstring": "*", "ext-tokenizer": "*", "ext-xml": "*", - "php": "^8.1.0" + "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.66.0", - "illuminate/view": "^10.48.25", - "larastan/larastan": "^2.9.12", - "laravel-zero/framework": "^10.48.25", + "friendsofphp/php-cs-fixer": "^3.82.2", + "illuminate/view": "^11.45.1", + "larastan/larastan": "^3.5.0", + "laravel-zero/framework": "^11.45.0", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^1.17.0", + "nunomaduro/termwind": "^2.3.1", "pestphp/pest": "^2.36.0" }, "bin": [ @@ -7950,6 +8007,9 @@ ], "type": "project", "autoload": { + "files": [ + "overrides/Runner/Parallel/ProcessFactory.php" + ], "psr-4": { "App\\": "app/", "Database\\Seeders\\": "database/seeders/", @@ -7979,7 +8039,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-01-14T16:20:53+00:00" + "time": "2025-07-10T18:09:32+00:00" }, { "name": "laravel/sail", diff --git a/config/comment.php b/config/comment.php index 293683a3..45db6e6d 100644 --- a/config/comment.php +++ b/config/comment.php @@ -1,10 +1,10 @@ 150, - 'max_depth' => 7, // 1-indexed, 1 is the start - 'pagination_limit' => 150, - 'default_pagination_limit' => 5, + 'max_replies' => 150, // Max replies for a comment + 'max_depth' => 7, // Count of comment + replies. + 'pagination_limit' => 150, // Should be larger than or equal to max_replies. Max amount of comments (including replies) allowed to be returned to the page each 'view comments' button press. + 'default_pagination_limit' => 10, // ideal amount of comments to load (can be over) each 'view comments' button press 'commentable_keys' => ['review', 'comment', 'edit', 'resource'], - 'sortable_options' => ['latest', 'top', 'bottom', 'controversial', 'mine'] -]; \ No newline at end of file + 'sortable_options' => ['latest', 'top', 'bottom', 'controversial', 'mine'], +]; diff --git a/config/computerScienceResource.php b/config/computerScienceResource.php index e7a66c61..9a9272cc 100644 --- a/config/computerScienceResource.php +++ b/config/computerScienceResource.php @@ -4,4 +4,4 @@ 'platforms' => ['book', 'podcast', 'youtube_channel', 'blog', 'website', 'organization', 'bootcamp', 'newsletter', 'workshop', 'course', 'forum', 'mobile_app', 'desktop_app', 'magazine'], 'difficulties' => ['beginner', 'industry_simple', 'industry_standard', 'industry_professional', 'academic'], 'pricings' => ['free', 'paid', 'freemium'], -]; \ No newline at end of file +]; diff --git a/config/fortify.php b/config/fortify.php index 0551d1d6..5b277307 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -73,7 +73,7 @@ | */ - 'home' => '/dashboard', + 'home' => '/', /* |-------------------------------------------------------------------------- diff --git a/config/jetstream.php b/config/jetstream.php index 1fd04dfa..9c0c4132 100644 --- a/config/jetstream.php +++ b/config/jetstream.php @@ -59,9 +59,9 @@ 'features' => [ // Features::termsAndPrivacyPolicy(), - // Features::profilePhotos(), // Features::api(), // Features::teams(['invitations' => true]), + // Features::profilePhotos(), Features::accountDeletion(), ], diff --git a/config/socialstream.php b/config/socialstream.php index 902a0176..cca8b6e5 100644 --- a/config/socialstream.php +++ b/config/socialstream.php @@ -19,10 +19,10 @@ Features::providerAvatars(), Features::refreshOAuthTokens(), ], - 'home' => '/dashboard', + 'home' => '/', 'redirects' => [ - 'login' => '/dashboard', - 'register' => '/dashboard', + 'login' => '/', + 'register' => '/', 'login-failed' => '/login', 'registration-failed' => '/register', 'provider-linked' => '/user/profile', diff --git a/config/tags.php b/config/tags.php index 628dc1a6..cd614908 100644 --- a/config/tags.php +++ b/config/tags.php @@ -24,5 +24,5 @@ * The fully qualified class name of the pivot model. */ 'class_name' => Illuminate\Database\Eloquent\Relations\MorphPivot::class, - ] + ], ]; diff --git a/config/upvotes.php b/config/upvotes.php index 8c0b1f57..085f5eda 100644 --- a/config/upvotes.php +++ b/config/upvotes.php @@ -1,5 +1,5 @@ ['review', 'comment', 'edit', 'resource'] -]; \ No newline at end of file + 'upvotable_keys' => ['review', 'comment', 'edit', 'resource'], +]; diff --git a/database/factories/CommentFactory.php b/database/factories/CommentFactory.php index 4c6c22a9..6cc2bf2d 100644 --- a/database/factories/CommentFactory.php +++ b/database/factories/CommentFactory.php @@ -2,11 +2,10 @@ namespace Database\Factories; -use App\Events\CommentCreated; -use Illuminate\Database\Eloquent\Factories\Factory; -use App\Models\User; use App\Models\Comment; +use App\Models\User; use App\Services\ModelResolverService; +use Illuminate\Database\Eloquent\Factories\Factory; /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Comment> @@ -19,17 +18,17 @@ class CommentFactory extends Factory * @return array */ - // TODO: Double check this logic + // TODO: Double check this logic public function definition(): array { // Pick a random commentable type from config. $commentableKey = $this->faker->randomElement(['comment', 'resource']); $modelResolver = app(ModelResolverService::class); $modelClass = $modelResolver->getModelClass($commentableKey); - + // Use an existing user or create one. $user = User::inRandomOrder()->first() ?? User::factory()->create(); - + // If the commentable type is a Comment, it means this new comment is a reply. if ($modelClass === Comment::class) { // Get an existing comment or create one if none exists. @@ -37,7 +36,7 @@ public function definition(): array $commentableId = $existingComment->commentable_id; $commentableType = $existingComment->commentable_type; - + // Since it's a recursive comment, the existing comment becomes the parent. $parent = $existingComment; $parentCommentId = $parent->id; @@ -49,13 +48,13 @@ public function definition(): array $commenting = $modelClass::inRandomOrder()->first() ?? $modelClass::factory()->create(); $commentableId = $commenting->id; $commentableType = $modelClass; - + // For non-comment targets we always create a top-level comment. $parentCommentId = null; $rootCommentId = null; $depth = 1; } - + return [ 'user_id' => $user->id, 'content' => $this->faker->paragraph, @@ -67,17 +66,4 @@ public function definition(): array 'children_count' => 0, ]; } - - public function configure() - { - // TODO: Consider making the increment part of the dispatch event? - return $this->afterCreating(function (Comment $comment) { - if ($comment->root_comment_id) { - Comment::where('id', $comment->root_comment_id) - ->increment('children_count'); - } - - CommentCreated::dispatch($comment->commentable_id, $comment->commentable_type); - }); - } } diff --git a/database/factories/ComputerScienceResourceFactory.php b/database/factories/ComputerScienceResourceFactory.php index 2a7cc9bc..6bd041f8 100644 --- a/database/factories/ComputerScienceResourceFactory.php +++ b/database/factories/ComputerScienceResourceFactory.php @@ -5,8 +5,8 @@ use App\Events\TagFrequencyChanged; use App\Models\ComputerScienceResource; use App\Models\User; -use Illuminate\Support\Facades\Log; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Http\UploadedFile; /** * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ComputerScienceResource> @@ -16,7 +16,9 @@ class ComputerScienceResourceFactory extends Factory protected $model = ComputerScienceResource::class; protected ?array $topicTags = null; + protected ?array $programmingLanguageTags = null; + protected ?array $generalTags = null; public function definition(): array @@ -25,11 +27,14 @@ public function definition(): array $difficulties = config('computerScienceResource.difficulties'); $pricings = config('computerScienceResource.pricings'); + $fakeImage = UploadedFile::fake()->image('resource.jpg'); + $imagePath = $fakeImage->store('resource', 'public'); + return [ 'name' => fake()->name(), 'description' => fake()->realText(), 'user_id' => User::inRandomOrder()->first() ?? User::factory()->create(), - 'image_url' => 'https://cdn.iconscout.com/icon/free/png-256/free-leetcode-logo-icon-download-in-svg-png-gif-file-formats--technology-social-media-company-vol-1-pack-logos-icons-3030025.png', + 'image_path' => $imagePath, 'page_url' => fake()->url(), 'platforms' => fake()->randomElements($platforms, rand(1, 3)), 'difficulty' => fake()->randomElement($difficulties), @@ -42,6 +47,7 @@ public function setTags(array $topic = [], array $language = [], array $general $this->topicTags = $topic; $this->programmingLanguageTags = $language; $this->generalTags = $general; + return $this; } diff --git a/database/factories/ResourceEditsFactory.php b/database/factories/ResourceEditsFactory.php index 3bd842da..19169c48 100644 --- a/database/factories/ResourceEditsFactory.php +++ b/database/factories/ResourceEditsFactory.php @@ -2,9 +2,9 @@ namespace Database\Factories; +use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; use App\Models\User; -use App\Models\ComputerScienceResource; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -20,6 +20,30 @@ public function definition(): array $difficulties = config('computerScienceResource.difficulties'); $pricings = config('computerScienceResource.pricings'); + $possibleChanges = [ + 'name' => $this->faker->name(), + 'description' => $this->faker->realText(), + 'image_path' => 'https://cdn.iconscout.com/icon/free/png-256/free-leetcode-logo-icon-download-in-svg-png-gif-file-formats--technology-social-media-company-vol-1-pack-logos-icons-3030025.png', + 'page_url' => $this->faker->url(), + 'platforms' => $this->faker->randomElements($platforms, rand(1, 3)), + 'difficulty' => $this->faker->randomElement($difficulties), + 'pricing' => $this->faker->randomElement($pricings), + 'topic_tags' => ['data structures', 'algorithms'], + 'programming_language_tags' => ['python'], + 'general_tags' => ['interactive', 'challenging'], + ]; + + // Select a random subset of keys to include in the proposed changes. + $proposedKeys = $this->faker->randomElements( + array_keys($possibleChanges), + $this->faker->numberBetween(1, count($possibleChanges)) + ); + + $proposedChanges = []; + foreach ($proposedKeys as $key) { + $proposedChanges[$key] = $possibleChanges[$key]; + } + return [ 'computer_science_resource_id' => function () { return ComputerScienceResource::inRandomOrder()->firstOr(function () { @@ -34,19 +58,7 @@ public function definition(): array 'edit_title' => $this->faker->sentence, 'edit_description' => $this->faker->paragraph, - // Copied fields from the resource - 'name' => $this->faker->name(), - 'description' => $this->faker->realText(), - 'image_url' => 'https://cdn.iconscout.com/icon/free/png-256/free-leetcode-logo-icon-download-in-svg-png-gif-file-formats--technology-social-media-company-vol-1-pack-logos-icons-3030025.png', - 'page_url' => $this->faker->url(), - - 'platforms' => $this->faker->randomElements($platforms, rand(1, 3)), - 'difficulty' => $this->faker->randomElement($difficulties), - 'pricing' => $this->faker->randomElement($pricings), - - 'topic_tags' => ['data structures', 'algorithms'], - 'programming_language_tags' => ['python'], - 'general_tags' => ['interactive', 'challenging'], + 'proposed_changes' => $proposedChanges, ]; } } diff --git a/database/factories/ResourceReviewFactory.php b/database/factories/ResourceReviewFactory.php index 16f2a035..9ea44821 100644 --- a/database/factories/ResourceReviewFactory.php +++ b/database/factories/ResourceReviewFactory.php @@ -2,7 +2,6 @@ namespace Database\Factories; -use App\Events\ResourceReviewProcessed; use App\Models\ComputerScienceResource; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; @@ -38,21 +37,4 @@ public function definition(): array 'cons' => $this->faker->words(mt_rand(1, 5)), ]; } - - /** - * Configure the model factory. - * - * @return static - */ - public function configure(): static - { - return $this->afterCreating(function ($resourceReview) { - // Dispatch the event after creating the resource review - ResourceReviewProcessed::dispatch( - $resourceReview->computer_science_resource_id, - null, - $resourceReview->attributesToArray() - ); - }); - } } diff --git a/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php b/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php index 1a97f74e..3b58e1a4 100644 --- a/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php +++ b/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php @@ -1,9 +1,9 @@ id(); @@ -21,7 +21,7 @@ public function up(): void $table->string('name')->fulltext(); $table->text('description')->fulltext(); - $table->string('image_url')->nullable(); + $table->string('image_path')->nullable(); // TODO: Have a url for each platform the resource is on. $table->string('page_url'); diff --git a/database/migrations/2025_02_07_023923_create_upvotes_table.php b/database/migrations/2025_02_07_023923_create_upvotes_table.php index feb609bb..1ea7b007 100644 --- a/database/migrations/2025_02_07_023923_create_upvotes_table.php +++ b/database/migrations/2025_02_07_023923_create_upvotes_table.php @@ -18,9 +18,7 @@ public function up(): void $table->foreignIdFor(User::class)->index(); $table->morphs('upvotable'); - $table->unique(['upvotable_id','upvotable_type', 'user_id']); - - $table->timestamps(); + $table->unique(['upvotable_id', 'upvotable_type', 'user_id']); }); } diff --git a/database/migrations/2025_02_09_172232_create_upvote_summaries_table.php b/database/migrations/2025_02_09_172232_create_upvote_summaries_table.php index 2654e0e6..aa770c64 100644 --- a/database/migrations/2025_02_09_172232_create_upvote_summaries_table.php +++ b/database/migrations/2025_02_09_172232_create_upvote_summaries_table.php @@ -1,6 +1,5 @@ integer('total_votes')->storedAs('upvotes + downvotes'); $table->integer('controversy')->storedAs('(upvotes + downvotes) - ABS(upvotes - downvotes)'); - $table->timestamps(); }); } diff --git a/database/migrations/2025_02_14_225555_create_resource_reviews_table.php b/database/migrations/2025_02_14_225555_create_resource_reviews_table.php index f58acb54..d05f9f4a 100644 --- a/database/migrations/2025_02_14_225555_create_resource_reviews_table.php +++ b/database/migrations/2025_02_14_225555_create_resource_reviews_table.php @@ -17,10 +17,10 @@ public function up(): void $table->id(); $table->foreignIdFor(User::class)->index(); $table->foreignIdFor(ComputerScienceResource::class)->index(); - + $table->unique([ 'user_id', - 'computer_science_resource_id' + 'computer_science_resource_id', ]); // Text diff --git a/database/migrations/2025_02_18_225525_create_resource_review_summaries_table.php b/database/migrations/2025_02_18_225525_create_resource_review_summaries_table.php index 8760d449..0e1fd463 100644 --- a/database/migrations/2025_02_18_225525_create_resource_review_summaries_table.php +++ b/database/migrations/2025_02_18_225525_create_resource_review_summaries_table.php @@ -23,29 +23,29 @@ public function up(): void $table->bigInteger('user_friendliness')->default(0); $table->bigInteger('updates')->default(0); - $table->decimal('community_rating')->storedAs(" + $table->decimal('community_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE community / review_count END - ")->index(); + ')->index(); - $table->decimal('teaching_clarity_rating')->storedAs(" + $table->decimal('teaching_clarity_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE teaching_clarity / review_count END - ")->index(); + ')->index(); - $table->decimal('engagement_rating')->storedAs(" + $table->decimal('engagement_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE engagement / review_count END - ")->index(); + ')->index(); - $table->decimal('practicality_rating')->storedAs(" + $table->decimal('practicality_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE practicality / review_count END - ")->index(); + ')->index(); - $table->decimal('user_friendliness_rating')->storedAs(" + $table->decimal('user_friendliness_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE user_friendliness / review_count END - ")->index(); + ')->index(); - $table->decimal('updates_rating')->storedAs(" + $table->decimal('updates_rating')->storedAs(' CASE WHEN review_count = 0 THEN 0 ELSE updates / review_count END - ")->index(); + ')->index(); $table->decimal('overall_rating') ->storedAs('(community_rating + @@ -57,8 +57,6 @@ public function up(): void ->index(); $table->integer('review_count')->default(0); - - $table->timestamps(); }); } diff --git a/database/migrations/2025_03_04_161737_create_comments_table.php b/database/migrations/2025_03_04_161737_create_comments_table.php index 471e0efa..bc0ed51b 100644 --- a/database/migrations/2025_03_04_161737_create_comments_table.php +++ b/database/migrations/2025_03_04_161737_create_comments_table.php @@ -1,10 +1,10 @@ id(); $table->timestamps(); - $table->morphs("commentable"); - $table->foreignIdFor(Comment::class, "root_comment_id")->nullable(); - $table->foreignIdFor(Comment::class, "parent_comment_id")->nullable(); + $table->morphs('commentable'); + $table->foreignIdFor(Comment::class, 'root_comment_id')->nullable(); + $table->foreignIdFor(Comment::class, 'parent_comment_id')->nullable(); $table->foreignIdFor(User::class); - $table->text("content"); + $table->text('content'); - $table->smallInteger("depth")->default(1)->index(); + $table->smallInteger('depth')->default(1)->index(); // Only root comment uses this - $table->unsignedInteger("children_count")->default(0); + $table->unsignedInteger('children_count')->default(0); }); } diff --git a/database/migrations/2025_03_24_154128_create_resource_edits_table.php b/database/migrations/2025_03_24_154128_create_resource_edits_table.php index 74886f2e..a1174203 100644 --- a/database/migrations/2025_03_24_154128_create_resource_edits_table.php +++ b/database/migrations/2025_03_24_154128_create_resource_edits_table.php @@ -16,7 +16,7 @@ public function up(): void Schema::create('resource_edits', function (Blueprint $table) { $table->id(); $table->timestamps(); - + // The resource we are editting $table->foreignIdFor(ComputerScienceResource::class)->constrained()->cascadeOnDelete(); // The user who created the edit @@ -26,27 +26,7 @@ public function up(): void $table->string('edit_title'); $table->text('edit_description'); - // Copied Schema of Computer Science Resource - $table->string('name')->fulltext(); - $table->text('description')->fulltext(); - - // TODO: have it be nullable or something - $table->string('image_url')->nullable(); - - $table->string('page_url'); - - $table->set('platforms', ['book', 'podcast', 'youtube_channel', 'blog', 'website', 'organization', 'bootcamp', 'newsletter', 'workshop', 'course', 'forum', 'mobile_app', 'desktop_app', 'magazine']) - ->index(); - $table->enum('difficulty', ['beginner', 'industry_simple', 'industry_standard', 'industry_professional', 'academic']) - ->index(); - $table->enum('pricing', ['free', 'premium', 'paid', 'freemium']) - ->index(); - - // Handle Tags: - // 'topic_tags', 'programming_language_tags', 'general_tags' - $table->json('topic_tags'); - $table->json('programming_language_tags'); - $table->json('general_tags'); + $table->json('proposed_changes'); }); } diff --git a/database/seeders/CommentSeeder.php b/database/seeders/CommentSeeder.php index 27d5e138..254a32ef 100644 --- a/database/seeders/CommentSeeder.php +++ b/database/seeders/CommentSeeder.php @@ -3,7 +3,6 @@ namespace Database\Seeders; use App\Models\Comment; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class CommentSeeder extends Seeder diff --git a/database/seeders/ComputerScienceResourceSeeder.php b/database/seeders/ComputerScienceResourceSeeder.php index 2e315472..60443886 100644 --- a/database/seeders/ComputerScienceResourceSeeder.php +++ b/database/seeders/ComputerScienceResourceSeeder.php @@ -2,10 +2,8 @@ namespace Database\Seeders; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use App\Models\ComputerScienceResource; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\Log; class ComputerScienceResourceSeeder extends Seeder { diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d85a8485..f751f0d2 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,12 +2,7 @@ namespace Database\Seeders; -use App\Models\User; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; -use Database\Seeders\ComputerScienceResourceSeeder; -use Database\Seeders\UserSeeder; -use Database\Seeders\ResourceReviewSeeder; -use Database\Seeders\ResourceEditsSeeder; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder diff --git a/database/seeders/ResourceEditsSeeder.php b/database/seeders/ResourceEditsSeeder.php index 5483162b..fee7eaca 100644 --- a/database/seeders/ResourceEditsSeeder.php +++ b/database/seeders/ResourceEditsSeeder.php @@ -3,7 +3,6 @@ namespace Database\Seeders; use App\Models\ResourceEdits; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class ResourceEditsSeeder extends Seeder diff --git a/database/seeders/ResourceReviewSeeder.php b/database/seeders/ResourceReviewSeeder.php index bfec227b..4c254491 100644 --- a/database/seeders/ResourceReviewSeeder.php +++ b/database/seeders/ResourceReviewSeeder.php @@ -3,7 +3,6 @@ namespace Database\Seeders; use App\Models\ResourceReview; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class ResourceReviewSeeder extends Seeder diff --git a/database/seeders/UpvoteSeeder.php b/database/seeders/UpvoteSeeder.php index 8cf15673..b2b982d9 100644 --- a/database/seeders/UpvoteSeeder.php +++ b/database/seeders/UpvoteSeeder.php @@ -2,7 +2,6 @@ namespace Database\Seeders; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class UpvoteSeeder extends Seeder diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php index a22feda1..47397fdc 100644 --- a/database/seeders/UserSeeder.php +++ b/database/seeders/UserSeeder.php @@ -3,9 +3,7 @@ namespace Database\Seeders; use App\Models\User; -use Database\Factories\ComputerScienceResourceFactory; use Hash; -use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Log; @@ -19,7 +17,7 @@ public function run(): void Log::info('Running UserSeeder'); User::factory(10)->create(); - if (!User::where('name','Allan Kong')->exists()) { + if (! User::where('name', 'Allan Kong')->exists()) { User::factory()->create( [ 'name' => 'Allan Kong', diff --git a/package-lock.json b/package-lock.json index b34a3100..2ffb1b30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,11 +4,14 @@ "requires": true, "packages": { "": { + "name": "html", "dependencies": { + "@fontsource/open-sans": "^5.2.5", "@primevue/forms": "^4.3.3", "@primevue/themes": "^4.3.3", "diff": "^7.0.0", "primevue": "^4.3.3", + "vue-picture-input": "^3.0.1", "yup": "^1.6.1" }, "devDependencies": { @@ -512,6 +515,14 @@ "node": ">=18" } }, + "node_modules/@fontsource/open-sans": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/open-sans/-/open-sans-5.2.5.tgz", + "integrity": "sha512-f0Ww6H+LB6GXA8UCgqs90h4djVttu3quH/1+wkfUY8b09mG1ESn4ACRBHYY78bsoeDXpaCyZh7eoGROBWplvAQ==", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -1361,11 +1372,10 @@ } }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, - "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -3683,6 +3693,12 @@ } } }, + "node_modules/vue-picture-input": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/vue-picture-input/-/vue-picture-input-3.0.1.tgz", + "integrity": "sha512-VGzqoH4iAN/S7L9RY2DyyLs4AjLt4QoWx6asT3y3PTXK2yFTXbUjx5qYdHrUJrVpInhvQwngEO/mJ7LhGXGFFg==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 2d70c7cc..43be8a2a 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,12 @@ "vue": "^3.3.13" }, "dependencies": { + "@fontsource/open-sans": "^5.2.5", "@primevue/forms": "^4.3.3", "@primevue/themes": "^4.3.3", - "primevue": "^4.3.3", "diff": "^7.0.0", + "primevue": "^4.3.3", + "vue-picture-input": "^3.0.1", "yup": "^1.6.1" } } diff --git a/public/images/Logo.svg b/public/images/Logo.svg new file mode 100644 index 00000000..75983a8b --- /dev/null +++ b/public/images/Logo.svg @@ -0,0 +1,608 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/LogoHead.svg b/public/images/LogoHead.svg new file mode 100644 index 00000000..72d60ff9 --- /dev/null +++ b/public/images/LogoHead.svg @@ -0,0 +1,380 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/test_logo.png b/public/images/test_logo.png deleted file mode 100644 index c7f82897..00000000 Binary files a/public/images/test_logo.png and /dev/null differ diff --git a/resources/css/app.css b/resources/css/app.css index 0de21207..b8459b52 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,7 +1,10 @@ +@import "@fontsource/open-sans"; + @tailwind base; @tailwind components; @tailwind utilities; + [x-cloak] { display: none; } diff --git a/resources/js/Components/ActionSection.vue b/resources/js/Components/ActionSection.vue index bdba111f..8100d78a 100644 --- a/resources/js/Components/ActionSection.vue +++ b/resources/js/Components/ActionSection.vue @@ -14,7 +14,7 @@ import SectionTitle from './SectionTitle.vue'; - + diff --git a/resources/js/Components/ApplicationLogo.vue b/resources/js/Components/ApplicationLogo.vue index aad5948d..a332446e 100644 --- a/resources/js/Components/ApplicationLogo.vue +++ b/resources/js/Components/ApplicationLogo.vue @@ -1,7 +1,3 @@ - - - - - + diff --git a/resources/js/Components/ApplicationMark.vue b/resources/js/Components/ApplicationMark.vue index a178c152..4ea89148 100644 --- a/resources/js/Components/ApplicationMark.vue +++ b/resources/js/Components/ApplicationMark.vue @@ -1,4 +1,3 @@ - - + diff --git a/resources/js/Components/AuthenticationCardLogo.vue b/resources/js/Components/AuthenticationCardLogo.vue index 34b64201..7ddd6b0d 100644 --- a/resources/js/Components/AuthenticationCardLogo.vue +++ b/resources/js/Components/AuthenticationCardLogo.vue @@ -4,14 +4,6 @@ import { Link } from '@inertiajs/vue3'; - - - - + diff --git a/resources/js/Components/Comments/CommentActionsForm.vue b/resources/js/Components/Comments/CommentActionsForm.vue index 739c9522..15ff2082 100644 --- a/resources/js/Components/Comments/CommentActionsForm.vue +++ b/resources/js/Components/Comments/CommentActionsForm.vue @@ -1,7 +1,9 @@ - + { - - Loading comments... + + @@ -205,7 +205,7 @@ onMounted(() => { View {{ commentsLeft }} Comments diff --git a/resources/js/Components/Comments/SingleComment.vue b/resources/js/Components/Comments/SingleComment.vue index f9dccb48..ecc90adb 100644 --- a/resources/js/Components/Comments/SingleComment.vue +++ b/resources/js/Components/Comments/SingleComment.vue @@ -1,8 +1,9 @@ - - + :user-vote="comment.user_vote" > - - - - - {{ users.get(comment.id)?.name }} - - {{ formattedDate }} - + + + + - - - {{ comment.content }} + + {{ comment.content }} - - + + + diff --git a/resources/js/Components/Comments/SortUpvotesByDropdown.vue b/resources/js/Components/Comments/SortUpvotesByDropdown.vue index d5160eac..0f6dda9c 100644 --- a/resources/js/Components/Comments/SortUpvotesByDropdown.vue +++ b/resources/js/Components/Comments/SortUpvotesByDropdown.vue @@ -12,9 +12,9 @@ const props = defineProps({ const selectedSort = ref(props.initialValue); const sortOptions = [ - { label: "Hot", value: "hot" }, { label: "Top", value: "top" }, { label: "Bottom", value: "bottom" }, + { label: "Hot", value: "hot" }, { label: "Controversial", value: "controversial" }, { label: "Total Votes", value: "total_votes" }, { label: "Latest", value: "latest" }, diff --git a/resources/js/Components/DangerButton.vue b/resources/js/Components/DangerButton.vue index f57db898..64136380 100644 --- a/resources/js/Components/DangerButton.vue +++ b/resources/js/Components/DangerButton.vue @@ -8,7 +8,7 @@ defineProps({ - + diff --git a/resources/js/Components/Diff/ImageDiffViewer.vue b/resources/js/Components/Diff/ImageDiffViewer.vue new file mode 100644 index 00000000..c925e2f3 --- /dev/null +++ b/resources/js/Components/Diff/ImageDiffViewer.vue @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + New Image Added + + + + Remove the Image + + + + diff --git a/resources/js/Components/Diff/SelectDiffViewer.vue b/resources/js/Components/Diff/SelectDiffViewer.vue new file mode 100644 index 00000000..6359ee49 --- /dev/null +++ b/resources/js/Components/Diff/SelectDiffViewer.vue @@ -0,0 +1,43 @@ + + + + + + - + {{ formattedOriginal }} + + + + + {{ formattedProposed }} + + + diff --git a/resources/js/Components/Diff/TagDiffViewer.vue b/resources/js/Components/Diff/TagDiffViewer.vue new file mode 100644 index 00000000..3a5bd4f3 --- /dev/null +++ b/resources/js/Components/Diff/TagDiffViewer.vue @@ -0,0 +1,62 @@ + + + + + + + - + {{ formatValue(tag) }} + + + + + + + {{ formatValue(tag) }} + + + + diff --git a/resources/js/Components/Diff/TextDiffViewer.vue b/resources/js/Components/Diff/TextDiffViewer.vue new file mode 100644 index 00000000..cca6f2b9 --- /dev/null +++ b/resources/js/Components/Diff/TextDiffViewer.vue @@ -0,0 +1,24 @@ + + + + + + {{ part.value }} + + + diff --git a/resources/js/Components/EmptyState.vue b/resources/js/Components/EmptyState.vue new file mode 100644 index 00000000..acd4d08f --- /dev/null +++ b/resources/js/Components/EmptyState.vue @@ -0,0 +1,31 @@ + + + + + + {{ title }} + {{ message }} + + diff --git a/resources/js/Components/Form/FormSaverChip.vue b/resources/js/Components/Form/FormSaverChip.vue new file mode 100644 index 00000000..9a2b16d3 --- /dev/null +++ b/resources/js/Components/Form/FormSaverChip.vue @@ -0,0 +1,24 @@ + + + + + + Saved locally + + diff --git a/resources/js/Components/Form/TagSelector.vue b/resources/js/Components/Form/TagSelector.vue index f127bcc7..393bd454 100644 --- a/resources/js/Components/Form/TagSelector.vue +++ b/resources/js/Components/Form/TagSelector.vue @@ -48,8 +48,14 @@ const handleSelect = (event) => { }; const handleKeydown = (event) => { - if (event.key === "Enter" && searchValue.value.trim()) { - addTag(searchValue.value.trim().toLowerCase()); + if (event.key === "Enter") { + if (searchValue.value.trim()) { + addTag(searchValue.value.trim().toLowerCase()); + // Clear the input after adding via Enter + nextTick(() => { + searchValue.value = ""; + }); + } event.preventDefault(); } }; diff --git a/resources/js/Components/FormSection.vue b/resources/js/Components/FormSection.vue index 81d1498d..6ba1d295 100644 --- a/resources/js/Components/FormSection.vue +++ b/resources/js/Components/FormSection.vue @@ -21,7 +21,7 @@ const hasActions = computed(() => !! useSlots().actions); @@ -29,7 +29,7 @@ const hasActions = computed(() => !! useSlots().actions); - + diff --git a/resources/js/Components/FrequentlyAskedQuestion.vue b/resources/js/Components/FrequentlyAskedQuestion.vue new file mode 100644 index 00000000..54a563e6 --- /dev/null +++ b/resources/js/Components/FrequentlyAskedQuestion.vue @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + diff --git a/resources/js/Components/ListInput.vue b/resources/js/Components/ListInput.vue index 0180ab97..67a30968 100644 --- a/resources/js/Components/ListInput.vue +++ b/resources/js/Components/ListInput.vue @@ -18,13 +18,26 @@ const props = defineProps({ const items = ref([...props.initialValues]); +// Watch for changes to initialValues (e.g., when loading from localStorage) +watch( + () => props.initialValues, + (newInitialValues) => { + // Only update if the content is actually different to avoid unnecessary updates + const currentFiltered = items.value.filter(item => item !== ""); + const newFiltered = newInitialValues.filter(item => item !== ""); + + if (JSON.stringify(currentFiltered) !== JSON.stringify(newFiltered)) { + items.value = [...newInitialValues]; + } + }, + { deep: true } +); + watch( items, (newItems) => { - emit( - "change", - newItems.filter((item) => item !== "") - ); + const filteredItems = newItems.filter((item) => item !== ""); + emit("change", filteredItems); }, { deep: true, immediate: true } ); @@ -57,6 +70,7 @@ const updateItem = (index, value) => { @input="(event) => updateItem(index, event.target.value)" placeholder="Enter an item" class="flex-grow" + :name="`list-item-${index}`" /> + + + + + + + + + + + + + diff --git a/resources/js/Components/NavLink.vue b/resources/js/Components/NavLink.vue index 7ab3f2f5..7fd50b69 100644 --- a/resources/js/Components/NavLink.vue +++ b/resources/js/Components/NavLink.vue @@ -9,8 +9,8 @@ const props = defineProps({ const classes = computed(() => { return props.active - ? 'inline-flex items-center px-1 pt-1 border-b-2 border-primary dark:border-indigo-600 text-sm font-medium leading-5 text-gray-900 dark:text-gray-100 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out' - : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-700 focus:outline-none focus:text-gray-700 dark:focus:text-gray-300 focus:border-gray-300 dark:focus:border-gray-700 transition duration-150 ease-in-out'; + ? 'inline-flex items-center px-1 pt-1 border-b-2 border-primary dark:border-primaryDark text-sm font-medium leading-5 text-primaryDark dark:text-secondary focus:outline-none focus:border-primaryDark transition duration-150 ease-in-out' + : 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-primary dark:text-secondary hover:text-primaryDark dark:hover:text-secondaryDark hover:border-secondaryDark focus:outline-none focus:text-primaryDark dark:focus:text-secondaryDark focus:border-secondaryDark transition duration-150 ease-in-out'; }); diff --git a/resources/js/Components/Navigation/BackButton.vue b/resources/js/Components/Navigation/BackButton.vue new file mode 100644 index 00000000..974dbb17 --- /dev/null +++ b/resources/js/Components/Navigation/BackButton.vue @@ -0,0 +1,18 @@ + + + + + + + diff --git a/resources/js/Components/Navigation/Navbar.vue b/resources/js/Components/Navigation/Navbar.vue index aef55d33..757cbbfb 100644 --- a/resources/js/Components/Navigation/Navbar.vue +++ b/resources/js/Components/Navigation/Navbar.vue @@ -30,6 +30,14 @@ const logout = () => { + + About Us + + { class="sm:hidden" > + + About Us + + { class="size-10 rounded-full object-cover" :src="$page.props.auth.user.profile_photo_url" :alt="$page.props.auth.user.name" + is="ProfilePhoto" /> diff --git a/resources/js/Components/Pagination/PaginateLinks.vue b/resources/js/Components/Pagination/PaginateLinks.vue index e23f65bc..333c1226 100644 --- a/resources/js/Components/Pagination/PaginateLinks.vue +++ b/resources/js/Components/Pagination/PaginateLinks.vue @@ -34,7 +34,7 @@ const nextLink = props.links[props.links.length - 1]?.url; - Showing {{ from }} to {{ to }} of {{ total }} {{ modelName }} + Showing {{ from ?? 0 }} to {{ to ?? 0 }} of {{ total ?? 0 }} {{ modelName }} diff --git a/resources/js/Components/Profile/ProfilePhoto.vue b/resources/js/Components/Profile/ProfilePhoto.vue new file mode 100644 index 00000000..ec57ea13 --- /dev/null +++ b/resources/js/Components/Profile/ProfilePhoto.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/resources/js/Components/Profile/UserProfile.vue b/resources/js/Components/Profile/UserProfile.vue new file mode 100644 index 00000000..a4d3d3a6 --- /dev/null +++ b/resources/js/Components/Profile/UserProfile.vue @@ -0,0 +1,45 @@ + + + + + + + + {{ user?.name ?? "Deleted User" }} + + + {{ formatDate(date) }} + + + + diff --git a/resources/js/Components/ResourceReviews.vue b/resources/js/Components/ResourceReviews.vue new file mode 100644 index 00000000..a1e82e6e --- /dev/null +++ b/resources/js/Components/ResourceReviews.vue @@ -0,0 +1,86 @@ + + + + + + + + + {{ showForm ? "Hide" : textOpen }} + + + + + + + + + + + + + + + + + + diff --git a/resources/js/Components/Resources/ResourceEdit/ProposeEditsButton.vue b/resources/js/Components/Resources/ResourceEdit/ProposeEditsButton.vue new file mode 100644 index 00000000..34f1469a --- /dev/null +++ b/resources/js/Components/Resources/ResourceEdit/ProposeEditsButton.vue @@ -0,0 +1,23 @@ + + + + + + + + Propose Edits + + + + diff --git a/resources/js/Components/Resources/ResourceEdit/ResourceEdits.vue b/resources/js/Components/Resources/ResourceEdit/ResourceEdits.vue index 2145d77d..19e43408 100644 --- a/resources/js/Components/Resources/ResourceEdit/ResourceEdits.vue +++ b/resources/js/Components/Resources/ResourceEdit/ResourceEdits.vue @@ -1,7 +1,9 @@ - - - - - - + + - - Propose Edits - - - Proposed Edits - - - - - Edit Title: + + + - {{ edit.edit_title }} + + {{ edit.edit_title }} + - Edit Description: - {{ edit.edit_description }} + + + + + {{ edit.edit_description }} + - - + + + + diff --git a/resources/js/Components/Resources/ResourceEdit/ResourceEditsFAQ.vue b/resources/js/Components/Resources/ResourceEdit/ResourceEditsFAQ.vue new file mode 100644 index 00000000..8e7e2c8a --- /dev/null +++ b/resources/js/Components/Resources/ResourceEdit/ResourceEditsFAQ.vue @@ -0,0 +1,230 @@ + + + + + + What are Proposed Edits? + + + People post mistakes, or resources can grow outdated. This + is why we thought it was vital that people can suggest + changes to existing resources. If you see that something + doesn't add up on a resource page, or that you want to + improve it, feel free to create a proposed edit. The + community will vote on the edits and given enough approvals, + the changes will be merged in! + + + + + + How does the voting system work? + + + + The number of votes required for a proposed edit to be + approved depends on how popular the resource is. We use + a logarithmic formula to ensure that highly popular + resources need more votes, but not an overwhelming + amount. + + + + Formula + public function requiredVotes(int $totalVotes): int +{ + // Take the minimum of total votes OR the logarithmic calculation + $votes = min($totalVotes, floor(log($totalVotes, 1.25)) + 1); + + // Ensure minimum of 3 votes is always required + return max(3, $votes); +} + + + + + How It Works + + + For resources with few votes: + The required approvals equals the total votes on + the resource + + + For popular resources: We use a + logarithmic scale (base 1.25) to prevent + approval requirements from becoming unreasonably + high + + + Minimum threshold: All proposed + edits require at least 3 approvals, regardless + of resource popularity + + + + + + + + + Total Votes on Resource + + + Required Approvals + + + Explanation + + + + + + + 0 + + + 3 + + + Minimum requirement applies + + + + + 1 + + + 3 + + + Minimum requirement applies + + + + + 5 + + + 5 + + + Equals total votes (below logarithmic + threshold) + + + + + 25 + + + 15 + + + log₁.₂₅(25) + 1 = 14 + 1 = 15 + + + + + 1,000 + + + 31 + + + log₁.₂₅(1,000) + 1 = 30 + 1 = 31 + + + + + 1,000,000 + + + 62 + + + log₁.₂₅(1,000,000) + 1 = 61 + 1 = 62 + + + + + + + + Why This System? + + + Fairness: New resources with + few votes don't need excessive approvals + + + Scalability: Popular resources + with thousands of votes don't require thousands + of approvals + + + Quality Control: The + logarithmic scale ensures that changes to + popular resources still require substantial + community consensus + + + Accessibility: The minimum of 3 + votes ensures that even unpopular resources can + be improved by a small group of engaged users + + + + + + + + diff --git a/resources/js/Components/Resources/ResourceItem.vue b/resources/js/Components/Resources/ResourceItem.vue index 0cc6887d..b6815421 100644 --- a/resources/js/Components/Resources/ResourceItem.vue +++ b/resources/js/Components/Resources/ResourceItem.vue @@ -5,6 +5,7 @@ import StarRating from "@/Components/StarRating/StarRating.vue"; import { Link } from "@inertiajs/vue3"; import { difficultyLabels, pricingLabels, platformLabels } from "@/Helpers/labels"; import { platformIcons, pricingIcons, difficultyIcons } from "@/Helpers/icons"; +import ResourceThumbnail from "./ResourceThumbnail.vue"; defineProps({ resource: { @@ -19,7 +20,7 @@ const emit = defineEmits(["upvote", "downvote"]); - + - - + - - - - - {{ resource.name }} - - - {{ resource.resource_created_on }} + + + + + + {{ resource.name }} + + + + + + + + + + + + {{ resource.review_summary?.review_count || 0 }} + + + {{ resource.resource_created_on }} + {{ resource.description }} @@ -89,12 +112,12 @@ const emit = defineEmits(["upvote", "downvote"]); - - + + Topics: - + {{ tag }} @@ -103,12 +126,12 @@ const emit = defineEmits(["upvote", "downvote"]); - - + + Languages: - + {{ tag }} @@ -117,12 +140,12 @@ const emit = defineEmits(["upvote", "downvote"]); - - + + Tags: - + {{ tag }} @@ -132,20 +155,5 @@ const emit = defineEmits(["upvote", "downvote"]); - - - - - - - - {{ resource.review_summary?.review_count || 0 }} - - - diff --git a/resources/js/Components/Resources/ResourceThumbnail.vue b/resources/js/Components/Resources/ResourceThumbnail.vue new file mode 100644 index 00000000..619506c1 --- /dev/null +++ b/resources/js/Components/Resources/ResourceThumbnail.vue @@ -0,0 +1,29 @@ + + + + + + diff --git a/resources/js/Components/Resources/Reviews/CreateResourceReview.vue b/resources/js/Components/Resources/Reviews/CreateResourceReview.vue index f6cd6bc8..c39ee72c 100644 --- a/resources/js/Components/Resources/Reviews/CreateResourceReview.vue +++ b/resources/js/Components/Resources/Reviews/CreateResourceReview.vue @@ -1,6 +1,5 @@ - - Write a Review + + + + + + {{ isEditingMode ? "Update Review" : "Write a Review" }} + { > - Title + Title + * + { - Description + Description + * { - + - Community + + Community + * + + { name="teaching_clarity" class="flex flex-col items-center" > - Teaching Clarity + + Teaching Clarity + * + { name="engagement" class="flex flex-col items-center" > - Engagement + + Engagement * + { name="practicality" class="flex flex-col items-center" > - Practicality + + Practicality + * + { name="user_friendliness" class="flex flex-col items-center" > - User Friendliness + + User Friendliness + * + { name="updates" class="flex flex-col items-center" > - Updates + + Updates * + { + + + {{ error }} + + diff --git a/resources/js/Components/Resources/Reviews/ResourceReview.vue b/resources/js/Components/Resources/Reviews/ResourceReview.vue index 9e2f7a22..8aee07ea 100644 --- a/resources/js/Components/Resources/Reviews/ResourceReview.vue +++ b/resources/js/Components/Resources/Reviews/ResourceReview.vue @@ -1,45 +1,15 @@ - - - - - - - - - + + - - - - - - {{ review.title }} - - Rating: - - - - - - - - {{ review.description }} - + + {{ review.title }} + + - - - Pros - - - {{ pro }} - - - - - Cons - - - {{ con }} - - - + + Rating: + + + + - - - + + + {{ review.description }} + + + + + Pros + + - {{ feature.label }} - - - - - + {{ pro }} + + + + Cons + + + {{ con }} + + + + - - + + - Are you sure you want to cancel editing this review? Changes will not be saved. - - - - - + {{ feature.label }} + + + + diff --git a/resources/js/Components/Resources/Reviews/ResourceReviews.vue b/resources/js/Components/Resources/Reviews/ResourceReviews.vue index a8857366..22551a6a 100644 --- a/resources/js/Components/Resources/Reviews/ResourceReviews.vue +++ b/resources/js/Components/Resources/Reviews/ResourceReviews.vue @@ -1,50 +1,43 @@ - - - - - - {{ showForm ? 'Hide' : 'Write a Review' }} - - - - - - - - + - + + + diff --git a/resources/js/Components/Resources/Reviews/ToggleCreateReview.vue b/resources/js/Components/Resources/Reviews/ToggleCreateReview.vue new file mode 100644 index 00000000..07ae6fc2 --- /dev/null +++ b/resources/js/Components/Resources/Reviews/ToggleCreateReview.vue @@ -0,0 +1,59 @@ + + + + + + + + + {{ buttonText }} + + + + + + + + + diff --git a/resources/js/Components/Resources/Reviews/UpdateResourceReview.vue b/resources/js/Components/Resources/Reviews/UpdateResourceReview.vue deleted file mode 100644 index 965c9afe..00000000 --- a/resources/js/Components/Resources/Reviews/UpdateResourceReview.vue +++ /dev/null @@ -1,217 +0,0 @@ - - - - - Write a Review - - - - Title - - - - - - - Description - - - - - - - - Community - - - - - - Teaching Clarity - - - - - - Engagement - - - - - - Practicality - - - - - - User Friendliness - - - - - - Updates - - - - - - - - - Pros - (form.pros = val)" - /> - - - - - Cons - (form.cons = val)" - /> - - - - - - - - - - - diff --git a/resources/js/Components/SectionBorder.vue b/resources/js/Components/SectionBorder.vue index cf81fa76..421bd7fa 100644 --- a/resources/js/Components/SectionBorder.vue +++ b/resources/js/Components/SectionBorder.vue @@ -1,7 +1,5 @@ - - - + diff --git a/resources/js/Components/StarRating/StarRating.vue b/resources/js/Components/StarRating/StarRating.vue index 8d634991..4375481c 100644 --- a/resources/js/Components/StarRating/StarRating.vue +++ b/resources/js/Components/StarRating/StarRating.vue @@ -17,11 +17,15 @@ const props = defineProps({ } }); +const formattedRating = computed(() => { + return parseFloat((props.modelValue || 0).toFixed(2)); +}); + const stars = computed(() => { const rating = props.modelValue || 0; const fullStars = Math.floor(rating); const hasHalf = rating % 1 >= 0.5; - const emptyStars = props.maxStars - Math.ceil(rating); + const emptyStars = props.maxStars - fullStars - (hasHalf ? 1 : 0); return { full: fullStars, @@ -43,6 +47,7 @@ const getStarClass = (type) => { + {{ formattedRating }} +import { onMounted, ref } from 'vue'; + +defineProps({ + modelValue: String, + rows: { + type: Number, + default: 4 + }, + placeholder: { + type: String, + default: '' + } +}); + +defineEmits(['update:modelValue']); + +const textarea = ref(null); + +onMounted(() => { + if (textarea.value.hasAttribute('autofocus')) { + textarea.value.focus(); + } +}); + +defineExpose({ focus: () => textarea.value.focus() }); + + + + + diff --git a/resources/js/Components/TextInput.vue b/resources/js/Components/TextInput.vue index 87ff0b56..1679d5cc 100644 --- a/resources/js/Components/TextInput.vue +++ b/resources/js/Components/TextInput.vue @@ -21,7 +21,7 @@ defineExpose({ focus: () => input.value.focus() }); diff --git a/resources/js/Components/ToastHandler.vue b/resources/js/Components/ToastHandler.vue new file mode 100644 index 00000000..8fc4afa8 --- /dev/null +++ b/resources/js/Components/ToastHandler.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/resources/js/Components/Upvote/Upvotable.vue b/resources/js/Components/Upvote/Upvotable.vue index 4a52de97..3970afea 100644 --- a/resources/js/Components/Upvote/Upvotable.vue +++ b/resources/js/Components/Upvote/Upvotable.vue @@ -52,7 +52,7 @@ async function handleUpvote() { const response = await axios.post( route("upvote", { id: props.upvotableId, - type: props.upvotableKey, + typeKey: props.upvotableKey, }) ); userVote.value = response.data.userVote; @@ -77,12 +77,12 @@ async function handleDownvote() { const response = await axios.post( route("downvote", { id: props.upvotableId, - type: props.upvotableKey, + typeKey: props.upvotableKey, }) ); userVote.value = response.data.userVote; votes.value += response.data.changeFromVote; - + if (props.refresh) { router.reload({preserveScroll: true}); @@ -96,7 +96,7 @@ async function handleDownvote() { - - + @@ -142,7 +142,7 @@ async function handleDownvote() { - + diff --git a/resources/js/Components/Welcome.vue b/resources/js/Components/Welcome.vue deleted file mode 100644 index 1941cab2..00000000 --- a/resources/js/Components/Welcome.vue +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - Welcome to your Jetstream application! - - - - Laravel Jetstream provides a beautiful, robust starting point for your next Laravel application. Laravel is designed - to help you build your application using a development environment that is simple, powerful, and enjoyable. We believe - you should love expressing your creativity through programming, so we have spent time carefully crafting the Laravel - ecosystem to be a breath of fresh air. We hope you love it. - - - - - - - - - - - Documentation - - - - - Laravel has wonderful documentation covering every aspect of the framework. Whether you're new to the framework or have previous experience, we recommend reading all of the documentation from beginning to end. - - - - - Explore the documentation - - - - - - - - - - - - - - - Laracasts - - - - - Laracasts offers thousands of video tutorials on Laravel, PHP, and JavaScript development. Check them out, see for yourself, and massively level up your development skills in the process. - - - - - Start watching Laracasts - - - - - - - - - - - - - - - Tailwind - - - - - Laravel Jetstream is built with Tailwind, an amazing utility first CSS framework that doesn't get in your way. You'll be amazed how easily you can build and maintain fresh, modern designs with this wonderful framework at your fingertips. - - - - - - - - - - Authentication - - - - - Authentication and registration views are included with Laravel Jetstream, as well as support for user email verification and resetting forgotten passwords. So, you're free to get started with what matters most: building your application. - - - - - diff --git a/resources/js/Composables/useLocalStorageSaver.js b/resources/js/Composables/useLocalStorageSaver.js new file mode 100644 index 00000000..dd0f0fde --- /dev/null +++ b/resources/js/Composables/useLocalStorageSaver.js @@ -0,0 +1,83 @@ +import { ref, computed, onMounted, onUnmounted, watch } from "vue"; + +export function useLocalStorageSaver(form, localStorageKeyId, formFields, keyPrefix = 'edit-draft') { + const localStorageKey = computed(() => `${keyPrefix}-${localStorageKeyId}`); + const isSavedToLocalStorage = ref(false); + const isDataLoaded = ref(false); + + const hasFormContent = computed(() => { + return formFields.some(field => { + const value = form[field]; + if (Array.isArray(value)) { + return value.length > 0; + } + if (typeof value === 'string') { + return value.trim() !== ''; + } + return value !== null && value !== undefined; + }); + }); + + const saveToLocalStorage = () => { + if (hasFormContent.value) { + const formData = {}; + formFields.forEach(field => { + formData[field] = form[field]; + }); + formData.savedAt = new Date().toISOString(); + localStorage.setItem(localStorageKey.value, JSON.stringify(formData)); + isSavedToLocalStorage.value = true; + } else { + localStorage.removeItem(localStorageKey.value); + isSavedToLocalStorage.value = false; + } + }; + + const loadFromLocalStorage = () => { + const savedData = localStorage.getItem(localStorageKey.value); + if (savedData) { + try { + const parsedData = JSON.parse(savedData); + formFields.forEach(field => { + if (parsedData[field] !== undefined) { + form[field] = parsedData[field]; + } + }); + isSavedToLocalStorage.value = true; + } catch (error) { + console.error('Error loading saved data:', error); + localStorage.removeItem(localStorageKey.value); + isSavedToLocalStorage.value = false; + } + } + isDataLoaded.value = true; + }; + + let saveTimeout; + watch(form, () => { + clearTimeout(saveTimeout); + saveTimeout = setTimeout(() => { + saveToLocalStorage(); + }, 500); + }, { deep: true }); + + onMounted(() => { + loadFromLocalStorage(); + }); + + onUnmounted(() => { + clearTimeout(saveTimeout); + }); + + const clearLocalStorage = () => { + localStorage.removeItem(localStorageKey.value); + isSavedToLocalStorage.value = false; + }; + + return { + isSavedToLocalStorage, + isDataLoaded, + hasFormContent, + clearLocalStorage + }; +} diff --git a/resources/js/Helpers/dates.js b/resources/js/Helpers/dates.js new file mode 100644 index 00000000..8dccdc63 --- /dev/null +++ b/resources/js/Helpers/dates.js @@ -0,0 +1,14 @@ +// Simple date formatting helpers +export const formatDate = (date) => { + if (!date) return ''; + return new Date(date).toLocaleString(navigator.language, { + year: "numeric", + month: "short", + day: "numeric", + }); +}; + +export const formatDateTime = (date) => { + if (!date) return ''; + return new Date(date).toLocaleString(); +}; diff --git a/resources/js/Helpers/icons.js b/resources/js/Helpers/icons.js index 912ef828..c60145a0 100644 --- a/resources/js/Helpers/icons.js +++ b/resources/js/Helpers/icons.js @@ -17,7 +17,7 @@ export const platformIcons = { forum: 'mdi:forum', mobile_app: 'mdi:cellphone', desktop_app: 'mdi:desktop-classic', - magazine: 'mdi:magazine' + magazine: 'mdi:newspaper' }; // Pricing icons mapping diff --git a/resources/js/Helpers/labels.js b/resources/js/Helpers/labels.js index 39bf0b6d..8369c57f 100644 --- a/resources/js/Helpers/labels.js +++ b/resources/js/Helpers/labels.js @@ -7,6 +7,7 @@ export const platformsObject = [ { label: "Blog", value: "blog" }, { label: "Course", value: "course" }, { label: "Bootcamp", value: "bootcamp" }, + { label: "Organization", value: "organization" }, { label: "Youtube Channel", value: "youtube_channel" }, { label: "Newsletter", value: "newsletter" }, { label: "Podcast", value: "podcast" }, @@ -22,6 +23,7 @@ export const platformLabels = { book: "Book", blog: "Blog", course: "Course", + organization: "Organization", bootcamp: "Bootcamp", youtube_channel: "Youtube Channel", newsletter: "Newsletter", @@ -33,24 +35,6 @@ export const platformLabels = { magazine: "Magazine", }; -// export const platformsLabels = [ -// "Website", value: "website" }, -// { label: "Book", value: "book" }, -// { label: "Blog", value: "blog" }, -// { label: "Course", value: "course" }, -// { label: "Bootcamp", value: "bootcamp" }, -// { label: "Youtube Channel", value: "youtube_channel" }, -// { label: "Newsletter", value: "newsletter" }, -// { label: "Podcast", value: "podcast" }, -// { label: "Forum", value: "forum" }, -// { label: "Workshop", value: "workshop" }, -// { label: "Mobile app", value: "mobile_app" }, -// { label: "Desktop app", value: "desktop_app" }, -// { label: "Magazine", value: "magazine" }, -// ]; - - - export const pricingsObject = [ { label: "Free", value: "free" }, { label: "Paid", value: "paid" }, @@ -83,6 +67,10 @@ export const difficultyLabels = { academic: "Academic", }; +export const getPricingLabel = (pricing) => pricingLabels[pricing] || "Unknown"; + +export const getDifficultyLabel = (difficulty) => + difficultyLabels[difficulty] || "Unknown"; export const ratingLabels = { community: "Community", @@ -93,6 +81,8 @@ export const ratingLabels = { updates: "Updates", }; +export const getPlatformLabel = (platform) => platformLabels[platform] || "Unknown"; + /// Sorting export const resourceSortingLabels = [ { value: "top", label: "Top Votes (Best Score)" }, diff --git a/resources/js/Helpers/validation.js b/resources/js/Helpers/validation.js index c61944d0..d0ca2ff2 100644 --- a/resources/js/Helpers/validation.js +++ b/resources/js/Helpers/validation.js @@ -5,8 +5,7 @@ import { object, string, array, number } from "yup"; // -------------------------- export const resourceMandatoryFields = object({ name: string().required("Name is required").max(100, "Max 100 chars"), - page_url: string().url("Must be a valid URL").required("URL is required"), - image_url: string().url("Must be a valid image URL"), + page_url: string().url("Must be a valid URL (Need to have https://)").required("URL is required"), platforms: array().of(string()).min(1, "At least one platform is required"), description: string().required("Description is required").max(10000), difficulty: string().required("Difficulty level is required"), @@ -15,12 +14,19 @@ export const resourceMandatoryFields = object({ export const resourceMandatoryTags = object({ topic_tags: array() - .of(string().trim()) + .of(string().max(50)) .min(3, "At least three topics are required") .required("Topics are required"), }); -export const optionalFields = object({}); +export const optionalFields = object({ + programming_languages: array() + .of(string().max(50)), + general_tags: array() + .of(string().max(50)) +}); + +export const resourceFields = resourceMandatoryFields.concat(resourceMandatoryTags).concat(optionalFields); // ------------------------- // Resource Reviews @@ -54,6 +60,13 @@ export const resourceReviewFields = object({ .min(1, "Minimum is 1.") .max(5, "Maximum is 5."), pros: array() + .transform((_value, originalValue) => { + // Handle case where PrimeVue might pass a string instead of array + if (typeof originalValue === 'string') { + return originalValue.trim() ? [originalValue.trim()] : []; + } + return Array.isArray(originalValue) ? originalValue : []; + }) .of( string() .max(200, "Each pro must have 200 characters or less.") @@ -61,6 +74,13 @@ export const resourceReviewFields = object({ ) .required("Pros are required."), cons: array() + .transform((_value, originalValue) => { + // Handle case where PrimeVue might pass a string instead of array + if (typeof originalValue === 'string') { + return originalValue.trim() ? [originalValue.trim()] : []; + } + return Array.isArray(originalValue) ? originalValue : []; + }) .of( string() .max(200, "Each con must have 200 characters or less.") @@ -72,7 +92,23 @@ export const resourceReviewFields = object({ // -------------------------- // Resource Edits // -------------------------- -export const resourceEditsMandatoryFields = object({ - edit_title: string().required().max(100, "Max 100 chars"), - edit_description: string().required().max(10000), + +// A nullable version of the resource fields for the edit form. +export const nullableResourceFields = object({ + name: string().max(100, "Max 100 chars"), + page_url: string().url("Must be a valid URL (Need to have https://)"), + // No validation on image_file since it will be validated on backend + platforms: array().of(string()), + description: string().max(10000), + difficulty: string(), + pricing: string(), + topic_tags: array().of(string().max(50)), + programming_language_tags: array().of(string().max(50)), + general_tags: array().of(string().max(50)), +}); + +export const resourceEditsFields = object({ + edit_title: string().required("Title is required").max(100, "Max 100 chars"), + edit_description: string().required("Description is required").max(10000), + proposed_changes: nullableResourceFields, }); diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index a35dadbe..483f8669 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -1,44 +1,12 @@ @@ -48,7 +16,7 @@ onMounted(() => { - + diff --git a/resources/js/Pages/AboutUs.vue b/resources/js/Pages/AboutUs.vue new file mode 100644 index 00000000..d1417503 --- /dev/null +++ b/resources/js/Pages/AboutUs.vue @@ -0,0 +1,209 @@ + + + + + + + + + + + + About ComputerScienceResources.com + + + + + + + Software is a unique industry — most people + enter it as hobbyists, tinker with side + projects, and are constantly learning. + + + + Computer science is a wide field. There is a lot + to learn: from data science, algorithms, + databases and infrastructure. + + + + + + On the plus side, there are a ton of resources + that are out there. The problem? It is scattered + all over the internet - buried in newsletters, + YouTube playlists, GitHub repositories, and + outdated blog posts. + + + + It's often difficult to know what a resource + actually teaches, who is the intended audience, + or how well it's received by the community. + + + + + Our Mission + + + Our goal with ComputerScienceResources.com + is to make learning easier by organizing and + highlighting the best resources in computer + science and software engineering. We aim to + help the developer community thrive by + providing a central place to discover, + review, and share valuable content. + + + + + In addition, we provide the community the + opportunity to give back to the website. + Everyone can post resources, review resources, + and suggest edits to any existing resource + description. + + + + On the more technical side, our github will be + open to discussion and feedback soon! Don't be + afraid to reach out there! + + + + ComputerScienceResources.com values the + community, because in the end, we all love + software! + + + + + + + Frequently Asked Questions + + + + Who is this site for? + + + Everyone! Everyone can all learn computer + science. We include resources for seniors + and children. This site is for everyone. + + + + + + Why is Computer Science Resources not open + source if you love the community? + + + It will be :). We wanted to keep it closed + source in the early release to prevent + people from forking the codebase and + stealing the project's credit. Furthermore, + we need to do some refactoring to make it + more appealing to those who wish to + contribute to the codebase. We will be happy + to open source it in the near future with + ideally 200 users minimum. + + + + + + How does Computer Science Resources make + money? + + + We don't plan to make anything locked behind + a paywall anytime in the future. The plans + for monetization will be from donations, or + non-intrusive ads in the future. + + + + + + What kind of resources are accepted? + + + We focus on comprehensive, structured, and + high-quality resources — things that provide + long-term learning value. That includes + interactive platforms, educational YouTube + channels, curated book series, technical + newsletters, and more. The more focused or + specialized the resource is, the better. + + + + + + Can I post my own course? + + + Only if it's well-received and genuinely + useful. This isn't a place to advertise + low-quality content, we will take those + down. We care more about substance than + self-promotion. If your course is loved by + the community, it's welcome. + + + + + + Is the site finished? + + + Of course not, we are still developing + things. There are a lot of features that + have not been implemented yet for the sake + of developing a release. If I were to make + the first release perfect, it wouldn't be + out here. So, we will be constantly + listening to community feedback and + improving the app. Feel free to point out + issues in the github repository tool and + github discussions tab! Some things that are + in the works are the ability to view + someone's profile, report malicious posts + and users, and favoriting a resource. + + + + + + Why is the Logo a Cat? + + + I love cats. What else can I say? I have two + cats and I simply love them. I think the + world is better with cats involved. + + + + + + + + + diff --git a/resources/js/Pages/Auth/Login.vue b/resources/js/Pages/Auth/Login.vue index dc6cd6c1..5a822b2f 100644 --- a/resources/js/Pages/Auth/Login.vue +++ b/resources/js/Pages/Auth/Login.vue @@ -76,9 +76,9 @@ const submit = () => { - + + class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> Forgot your password? @@ -91,5 +91,12 @@ const submit = () => { + + + + Not registered yet? Make an account + + diff --git a/resources/js/Pages/Auth/Register.vue b/resources/js/Pages/Auth/Register.vue index d42f4534..5ab5007d 100644 --- a/resources/js/Pages/Auth/Register.vue +++ b/resources/js/Pages/Auth/Register.vue @@ -118,5 +118,12 @@ const submit = () => { + + + + Already have an Account? Sign in + + diff --git a/resources/js/Pages/Dashboard.vue b/resources/js/Pages/Dashboard.vue deleted file mode 100644 index d2c067ff..00000000 --- a/resources/js/Pages/Dashboard.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - Dashboard - - - - - - - - - - - - diff --git a/resources/js/Pages/Profile/Show.vue b/resources/js/Pages/Profile/Show.vue index 887e0419..5092c241 100644 --- a/resources/js/Pages/Profile/Show.vue +++ b/resources/js/Pages/Profile/Show.vue @@ -17,56 +17,64 @@ defineProps({ - - - Profile - - - - - - - - - + + + + + + - - - - + + + - - - - + + + + - - - + v-if="$page.props.jetstream.canManageTwoFactorAuthentication && $page.props.socialstream.hasPassword" + class="bg-white shadow-md rounded-2xl p-6" + > + - - + + + - - - - + + + - - - - - + + + + diff --git a/resources/js/Pages/ResourceEdits/Create.vue b/resources/js/Pages/ResourceEdits/Create.vue index db3e8db3..735b30b7 100644 --- a/resources/js/Pages/ResourceEdits/Create.vue +++ b/resources/js/Pages/ResourceEdits/Create.vue @@ -1,28 +1,32 @@ - - - + + + - - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - + + + + Describe Your Change + + + Title + * + + + + - - - - - + + Description + * + + + + + - - - - - + + New Edited Resource + + + + + + + + - - - - + + + changedPicture = true" + @change="onImageChange" /> - - - - - - - - - - - - - + Reset Image + + + + + + + + - - - - - Topics? - + + + + + - - (formData.topic_tags = tags) - " + + + + + + - + + + + + + + + - + + - - - What Programming Languages are used (if - any)? - + + - (formData.programming_language_tags = - tags) - " + v-model="formData.proposed_changes.topic_tags" + @blur="validateField('proposed_changes.topic_tags')" + /> + - - - What else is it related to? - + + (formData.general_tags = tags) + v-model="formData.proposed_changes.programming_language_tags" + @blur="validateField('proposed_changes.programming_language_tags')" + /> + - - - Visit Resource - - - Posted by: - {{ - props.resource.user?.name ?? - "Unknown User" - }} - - - Created: - {{ - new Date( - props.resource.created_at - ).toLocaleString() - }} - - - Last updated: - {{ - new Date( - props.resource.updated_at - ).toLocaleString() - }} - - + + + + - - + + + Reset + + + + Reset the form + + + + Are you sure you want reset back to the + default values for this resource? You + will lose your saved changes. + + + + + Cancel + + + + Reset Form + + + + + + Submit Edit + + - - + + - + diff --git a/resources/js/Pages/ResourceEdits/Show.vue b/resources/js/Pages/ResourceEdits/Show.vue index 8c9adfcb..92bbda61 100644 --- a/resources/js/Pages/ResourceEdits/Show.vue +++ b/resources/js/Pages/ResourceEdits/Show.vue @@ -1,559 +1,432 @@ - - - - - - - - - - {{ editedResource.edit_title }} - - {{ editedResource.edit_description }} - - - - + + + + + + + + + + + + + + {{ editedResource.edit_title }} + + + + {{ editedResource.edit_description }} + + + + + - Merge - + + + Merge Changes + + - + - - - - - - Edited Version - - - + + + + - - {{ field.label }}: - - - {{ - field.formatter - ? field.formatter( - props.editedResource[ - field.key - ] - ) - : props.editedResource[ - field.key - ] - }} - - - - - - - Platforms: - - - - + Proposed Changes + - - - + - Tags: - - - - - + + {{ field.label }}: + + + Image Removed + + + + + + + {{ + field.formatter + ? field.formatter( + field.proposedValue + ) + : field.proposedValue + }} + + + No changes were proposed. + - - - - Original Version - - + + - - {{ field.label }}: - - - {{ - field.formatter - ? field.formatter( - props - .originalResource[ - field.key - ] - ) - : props.originalResource[ - field.key - ] - }} - + + + Current Version + + - - - + - Platforms: - - - + + {{ field.label }}: + + + Image Removed + + + + + + + {{ + field.formatter + ? field.formatter( + field.originalValue + ) + : field.originalValue + }} + - - - - - - Tags: - - - - - - + No changes to compare. + - - - - - - + + + + - - {{ field.label }} Diff: - - - - - - - - - Platforms Diff: - - - - {{ - Array.isArray(part.value) - ? part.value.join(", ") - : part.value - }} - + + {{ field.label }} + - - - - - - - - {{ - tagType === "topic_tags" - ? "Topic" - : tagType === - "programming_language_tags" - ? "Programming Language" - : "General" - }} - Tags Diff: - - - - {{ - Array.isArray( - part.value - ) - ? part.value.join( - ", " - ) - : part.value - }} - - - + + - - - No differences found. - + + No changes to display in diff. + - - - - - - - - Rejected - - - - - Reject Changes - - + + + + + + + + Rejected + + + + + + Reject Changes + + - - - - {{ votes }} Approval{{ - votes === 1 ? "" : "s" - }} - - + + + + {{ votes }} + + Approval{{ votes === 1 ? "" : "s" }} + + + - - - - Approved! - - - - - Approve Changes - - - + + + + + Approved! + + + + + + Approve Changes + + + + - + diff --git a/resources/js/Pages/Resources/Create.vue b/resources/js/Pages/Resources/Create.vue index b6013d7b..4307f1b4 100644 --- a/resources/js/Pages/Resources/Create.vue +++ b/resources/js/Pages/Resources/Create.vue @@ -1,4 +1,5 @@ @@ -44,46 +79,202 @@ const handleFormChange = (newFormData) => { - - - - Add a New Resource - - - Details - Topics - Tags - - - - - - - - - - - + + + + + + Clear All + + + + + Add a New Resource + + + Details + Topics + Tags + + + + - - - - + @next="() => (stepperValue = '2')" + > + + + (stepperValue = '1')" + @next="() => (stepperValue = '3')" + > + + + + (stepperValue = '2')" + @next="submitForm" + /> + + + + + + + Reset the form + + + Are you sure you want reset your fields for this + potential new resource? You will lose your saved + changes. + + + + + Cancel + + + + Reset Form + + + + + + + + + Submission Guidelines + + + + + + Resource Scope + + + This site features comprehensive learning + resources rather than isolated materials. + Resources should provide ongoing value or + structured learning experiences rather than a + single-use reference. + + + Our goal is to provide ways for developers to + hone their skills. This industry is filled with + passion, so we should make it easier to find + more ways to learn. + + + + + + Types of Content Accepted + + + + Platforms, websites, and tools that offer + interactive learning + + + Collections of educational content, such as + YouTube channels or book series + + + Guides or repositories that serve as + long-term learning hubs + + + Newsletters that consistently release + content to date + + + Organizations that can provide software + career advising + + + + Anything to help people learn more about + software: from hardware, system design, to + project management. The more specialized the + resources are, the better. + + + + + + Exceptions + + + + An individual book may be included if it is + exceptionally well-regarded and widely + recommended as a foundational resource + + + Entertainment streamers and YouTubers can be + included given that they are very popular + whilst still informative + + + Do not post your paid courses unless they + are well received - this is not a platform + to advertise unwanted courses + + + + + + + What's Not Included + + + + Standalone videos, single blog posts, or + one-off articles + + + Resources that are too broad and do not + contain a singular focus + + + Things not related to learning about + computer science or software engineering + + + Lifestyle or personal finance content + (beyond reasonable project management) + + + + In the end, we trust you to be reasonable. + + + + diff --git a/resources/js/Pages/Resources/Form/MandatoryFields.vue b/resources/js/Pages/Resources/Form/MandatoryFields.vue index 62b3ddc3..9cb903df 100644 --- a/resources/js/Pages/Resources/Form/MandatoryFields.vue +++ b/resources/js/Pages/Resources/Form/MandatoryFields.vue @@ -3,22 +3,19 @@ import InputText from "primevue/inputtext"; import Textarea from "primevue/textarea"; import MultiSelect from "primevue/multiselect"; import { Button } from "primevue"; -import { Form, FormField } from "@primevue/forms"; -import { yupResolver } from "@primevue/forms/resolvers/yup"; import PrimeVueFormError from "@/Components/Form/PrimeVueFormError.vue"; - +import PictureInput from "vue-picture-input"; import Select from "primevue/select"; -import { defineProps, defineEmits, watch, ref } from "vue"; +import { defineProps, defineEmits, ref, watch } from "vue"; import { platformsObject, pricingsObject, difficultiesObject, } from "@/Helpers/labels"; -import { reactive } from "vue"; import { resourceMandatoryFields } from "@/Helpers/validation"; const props = defineProps({ - form: { + formData: { type: Object, required: true, }, @@ -26,120 +23,108 @@ const props = defineProps({ const emit = defineEmits(["change", "next"]); -// The form data that is being filled out -const formData = reactive({ - ...props.form, -}); +const errors = ref({}); + +function onImageChange(event) { + const file = event.target.files[0]; + props.formData.image_file = file; +} -// The validation schema -const schema = resourceMandatoryFields; -// PrimeVue Resolver -const resolver = ref(yupResolver(schema)); +const validateAndNext = async () => { + try { + await resourceMandatoryFields.validate(props.formData, { + abortEarly: false, + }); + errors.value = {}; + emit("next", props.formData); + } catch (e) { + const yupErrors = {}; + e.inner.forEach((error) => { + yupErrors[error.path] = error.errors; + }); + errors.value = yupErrors; + } +}; -// Update change watch( - formData, + () => props.formData, (newValue) => { emit("change", newValue); }, { deep: true } ); - -const validateAndNext = () => { - // Validate the form using the schema - schema - .validate(formData) - .then((validData) => { - emit("next", validData); - }) - .catch((error) => { - // If validation fails, you can handle the errors here - console.error("Validation failed:", error.errors); - }); -}; - + - + Name + >Name + * + + - + - + Resource Website URL + >Resource Website URL (Include https://) + * + - + - + Image URLImage Thumbnail - + - - - Image Preview: - - - - - + Resource Platforms + >Resource Platforms + * + { class="w-full" /> - + - + Description + >Description + * + - + - + Difficulty + >Difficulty + * + - + - + Pricing + >Pricing + * + - + + + + + - - - - diff --git a/resources/js/Pages/Resources/Form/TagsFields.vue b/resources/js/Pages/Resources/Form/TagsFields.vue index 00d1ef3b..d2320a94 100644 --- a/resources/js/Pages/Resources/Form/TagsFields.vue +++ b/resources/js/Pages/Resources/Form/TagsFields.vue @@ -2,6 +2,10 @@ import { ref, defineProps, defineEmits, watch } from "vue"; import TagSelector from "@/Components/Form/TagSelector.vue"; import Button from "primevue/button"; +import { yupResolver } from "@primevue/forms/resolvers/yup"; +import { Form } from "@primevue/forms"; +import PrimeVueFormError from "@/Components/Form/PrimeVueFormError.vue"; +import { optionalFields } from "@/Helpers/validation"; const props = defineProps({ form: { @@ -12,12 +16,21 @@ const props = defineProps({ const emit = defineEmits(["change", "next", "back"]); -// Reactive reference for form data -const formData = ref({ ...props.form }); +const errors = ref([]); +const schema = optionalFields; +const resolver = ref(yupResolver(schema)); +const validateAndNext = async () => { + schema + .validate(props.form) + .then(() => emit("next")) + .catch((error) => { + errors.value = error.errors; + }); +}; // Update change watch( - formData, + () => props.form, (newValue) => { emit("change", newValue); }, @@ -26,39 +39,41 @@ watch( - - - - What Programming Languages are used (if any)? - - + + + + + What Programming Languages are used (if any)? + + - - - What else is it related to? - + + + What else is it related to? + + - - + + - - - emit('back')" - /> - - emit('next')" - /> - + + + emit('back')" + label="Back" + severity="secondary" + icon="pi pi-arrow-left" + /> + + + diff --git a/resources/js/Pages/Resources/Form/TopicsFields.vue b/resources/js/Pages/Resources/Form/TopicsFields.vue index 856e5a48..09ff0b97 100644 --- a/resources/js/Pages/Resources/Form/TopicsFields.vue +++ b/resources/js/Pages/Resources/Form/TopicsFields.vue @@ -4,7 +4,7 @@ import TagSelector from "@/Components/Form/TagSelector.vue"; import Button from "primevue/button"; import { yupResolver } from "@primevue/forms/resolvers/yup"; import { resourceMandatoryTags } from "@/Helpers/validation"; -import { Form, FormField } from "@primevue/forms"; +import { Form } from "@primevue/forms"; import PrimeVueFormError from "@/Components/Form/PrimeVueFormError.vue"; const props = defineProps({ @@ -16,8 +16,6 @@ const props = defineProps({ const emit = defineEmits(["change", "next", "back"]); -// Reactive reference for form data -const formData = ref({ ...props.form }); const errors = ref([]); const schema = resourceMandatoryTags; @@ -26,7 +24,7 @@ const resolver = ref(yupResolver(schema)); // Update change watch( - formData, + () => props.form, (newValue) => { emit("change", newValue); }, @@ -36,32 +34,32 @@ watch( // Function to handle form submission const validateAndNext = async () => { schema - .validate(formData.value) + .validate(props.form) .then((_) => { - console.log("validated"); + errors.value = []; emit("next"); }) .catch((error) => { console.error("Validation failed:", error.errors); errors.value = error.errors; }); - console.log(formData); }; What topics does this resource cover? + * diff --git a/resources/js/Pages/Resources/Show.vue b/resources/js/Pages/Resources/Show.vue index 38258cc0..4d01b1c9 100644 --- a/resources/js/Pages/Resources/Show.vue +++ b/resources/js/Pages/Resources/Show.vue @@ -1,14 +1,23 @@
{{ users.get(comment.id)?.name }}
{{ comment.content }}
{{ message }}
+ {{ user?.name ?? "Deleted User" }} +
+ {{ edit.edit_description }} +
+ People post mistakes, or resources can grow outdated. This + is why we thought it was vital that people can suggest + changes to existing resources. If you see that something + doesn't add up on a resource page, or that you want to + improve it, feel free to create a proposed edit. The + community will vote on the edits and given enough approvals, + the changes will be merged in! +
+ The number of votes required for a proposed edit to be + approved depends on how popular the resource is. We use + a logarithmic formula to ensure that highly popular + resources need more votes, but not an overwhelming + amount. +
public function requiredVotes(int $totalVotes): int +{ + // Take the minimum of total votes OR the logarithmic calculation + $votes = min($totalVotes, floor(log($totalVotes, 1.25)) + 1); + + // Ensure minimum of 3 votes is always required + return max(3, $votes); +} +
public function requiredVotes(int $totalVotes): int +{ + // Take the minimum of total votes OR the logarithmic calculation + $votes = min($totalVotes, floor(log($totalVotes, 1.25)) + 1); + + // Ensure minimum of 3 votes is always required + return max(3, $votes); +}
{{ resource.description }}
- {{ review.description }} -
+ {{ review.description }} +
- Laravel Jetstream provides a beautiful, robust starting point for your next Laravel application. Laravel is designed - to help you build your application using a development environment that is simple, powerful, and enjoyable. We believe - you should love expressing your creativity through programming, so we have spent time carefully crafting the Laravel - ecosystem to be a breath of fresh air. We hope you love it. -
- Laravel has wonderful documentation covering every aspect of the framework. Whether you're new to the framework or have previous experience, we recommend reading all of the documentation from beginning to end. -
- - Explore the documentation - - - - - -
- Laracasts offers thousands of video tutorials on Laravel, PHP, and JavaScript development. Check them out, see for yourself, and massively level up your development skills in the process. -
- - Start watching Laracasts - - - - - -
- Laravel Jetstream is built with Tailwind, an amazing utility first CSS framework that doesn't get in your way. You'll be amazed how easily you can build and maintain fresh, modern designs with this wonderful framework at your fingertips. -
- Authentication and registration views are included with Laravel Jetstream, as well as support for user email verification and resetting forgotten passwords. So, you're free to get started with what matters most: building your application. -
+ Software is a unique industry — most people + enter it as hobbyists, tinker with side + projects, and are constantly learning. +
+ Computer science is a wide field. There is a lot + to learn: from data science, algorithms, + databases and infrastructure. +
+ On the plus side, there are a ton of resources + that are out there. The problem? It is scattered + all over the internet - buried in newsletters, + YouTube playlists, GitHub repositories, and + outdated blog posts. +
+ It's often difficult to know what a resource + actually teaches, who is the intended audience, + or how well it's received by the community. +
+ Our Mission +
+ Our goal with ComputerScienceResources.com + is to make learning easier by organizing and + highlighting the best resources in computer + science and software engineering. We aim to + help the developer community thrive by + providing a central place to discover, + review, and share valuable content. +
+ In addition, we provide the community the + opportunity to give back to the website. + Everyone can post resources, review resources, + and suggest edits to any existing resource + description. +
+ On the more technical side, our github will be + open to discussion and feedback soon! Don't be + afraid to reach out there! +
+ ComputerScienceResources.com values the + community, because in the end, we all love + software! +
- Topics? -
- What Programming Languages are used (if - any)? -
- Posted by: - {{ - props.resource.user?.name ?? - "Unknown User" - }} -
- Created: - {{ - new Date( - props.resource.created_at - ).toLocaleString() - }} -
- Last updated: - {{ - new Date( - props.resource.updated_at - ).toLocaleString() - }} -
{{ editedResource.edit_description }}
+ {{ editedResource.edit_description }} +
Image Removed
+ No changes were proposed. +
+ No changes to display in diff. +
+ This site features comprehensive learning + resources rather than isolated materials. + Resources should provide ongoing value or + structured learning experiences rather than a + single-use reference. +
+ Our goal is to provide ways for developers to + hone their skills. This industry is filled with + passion, so we should make it easier to find + more ways to learn. +
+ Anything to help people learn more about + software: from hardware, system design, to + project management. The more specialized the + resources are, the better. +
+ In the end, we trust you to be reasonable. +
{{ users.get(comment.id)?.name }}
- +{{ comment.content }}
+ +{{ comment.content }}
- -{{ formattedOriginal }}+{{ formatValue(tag) }}+{{ title }}
+{{ message }}
+