diff --git a/app/Http/Controllers/Admin/ResourceController.php b/app/Http/Controllers/Admin/ResourceController.php index 0355185d..72a2798d 100644 --- a/app/Http/Controllers/Admin/ResourceController.php +++ b/app/Http/Controllers/Admin/ResourceController.php @@ -2,10 +2,7 @@ namespace App\Http\Controllers\Admin; -use App\Http\Controllers\Controller; -use App\Http\Requests\Resource\BulkImageStoreRequest; -use App\Http\Requests\Resource\BulkVideoStoreRequest; -use App\Http\Requests\Resource\StoreResourceRequest; +use App\Http\Controllers\ResourceController as BaseResourceController; use App\Http\Requests\Resource\UpdateResourceRequest; use App\Models\Node; use App\Models\Resource; @@ -13,90 +10,33 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; -use Illuminate\Validation\ValidationException; -class ResourceController extends Controller +class ResourceController extends BaseResourceController { - /** - * Determine maximum allowed pending submissions for the user. - * Verified users: 100, Unverified users: 30. - */ - protected function getMaxPendingSubmissions(): int - { - return Auth::user()?->is_verified ? 100 : 30; - } - - /** - * Check if user would exceed their pending change requests quota. - * Throws standard ValidationException so it returns as a typed error in Inertia errors. - */ - protected function ensureUnderPendingLimit(int $incomingCount = 1): void - { - if (Auth::user()?->can('moderate resources')) { - return; - } - - $userId = Auth::id(); - $maxLimit = $this->getMaxPendingSubmissions(); - - $currentPending = ResourceChangeRequest::where('user_id', $userId) - ->where('status', 'pending') - ->count(); - - if (($currentPending + $incomingCount) > $maxLimit) { - throw ValidationException::withMessages([ - 'pending_limit' => "আপনি সর্বোচ্চ {$maxLimit}টি কন্টেন্ট আপলোড করার অনুরোধ করতে পারেন। আপনার আপলোডকৃত {$currentPending}টি কন্টেন্ট বর্তমানে পর্যালোচনাধীন রয়েছে, তাই অনুগ্রহ করে অপেক্ষা করুন। ", - ]); - } - } - - public function store(StoreResourceRequest $request) + public function update(UpdateResourceRequest $request, Resource $resource) { $validated = $request->validated(); - if ($request->hasFile('file')) { - $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); - } - - if ($request->user()->can('moderate resources')) { - $validated['user_id'] = Auth::id(); - Resource::create($validated); + $isModerator = $request->user()->can('moderate resources'); - return back()->with('success', 'Resource created successfully.'); + if (! $isModerator) { + $this->ensureCanSubmit(1); } - $this->ensureUnderPendingLimit(1); - - ResourceChangeRequest::recordCreate( - Auth::id(), - (int) $validated['node_id'], - $validated - ); - - return back()->with('success', 'Resource submitted for moderation.'); - } - - public function update(UpdateResourceRequest $request, Resource $resource) - { - $validated = $request->validated(); - if ($request->hasFile('file')) { - if ($request->user()->can('moderate resources') && $resource->file_path) { + if ($isModerator && $resource->file_path) { Storage::delete($resource->file_path); } $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); } - if ($request->user()->can('moderate resources')) { + if ($isModerator) { $resource->update($validated); return back()->with('success', 'Resource updated successfully.'); } - $this->ensureUnderPendingLimit(1); - ResourceChangeRequest::recordUpdate( Auth::id(), $resource, @@ -117,157 +57,13 @@ public function destroy(Resource $resource) return redirect()->back()->with('success', 'Resource deleted successfully.'); } - $this->ensureUnderPendingLimit(1); + $this->ensureCanSubmit(1); ResourceChangeRequest::recordDelete(Auth::id(), $resource); return redirect()->back()->with('success', 'Resource deletion request submitted for moderation.'); } - public function storeBulkImages(BulkImageStoreRequest $request) - { - $validated = $request->validated(); - $filesCount = count($request->file('files') ?? []); - $isModerator = $request->user()->can('moderate resources'); - - if (! $isModerator) { - $this->ensureUnderPendingLimit($filesCount); - } - - $userId = Auth::id(); - $nodeId = (int) $validated['node_id']; - - DB::transaction(function () use ($request, $validated, $userId, $nodeId, $isModerator) { - foreach ($request->file('files') as $index => $file) { - $filePath = $file->store('resources/images'); - $title = $validated['custom_titles'][$index]; - - if ($isModerator) { - Resource::create([ - 'user_id' => $userId, - 'node_id' => $nodeId, - 'title' => $title, - 'resource_type' => 'image', - 'file_path' => $filePath, - ]); - } else { - ResourceChangeRequest::recordCreate($userId, $nodeId, [ - 'title' => $title, - 'resource_type' => 'image', - 'file_path' => $filePath, - ]); - } - } - }); - - $message = $isModerator ? 'Images uploaded successfully.' : 'Images submitted for moderation.'; - - return back()->with('success', $message); - } - - public function storeBulkVideos(BulkVideoStoreRequest $request) - { - $validated = $request->validated(); - - $playlistUrl = $validated['playlist_url']; - - // Extract playlist ID - parse_str(parse_url($playlistUrl, PHP_URL_QUERY), $query); - - $playlistId = $query['list'] ?? null; - - if (! $playlistId) { - return back()->with('error', 'Invalid Youtube URL'); - } - - $videos = []; - $pageToken = null; - - do { - $response = Http::get('https://www.googleapis.com/youtube/v3/playlistItems', [ - 'part' => 'snippet', - 'playlistId' => $playlistId, - 'maxResults' => 50, - 'pageToken' => $pageToken, - 'key' => config('services.youtube.key'), - ]); - - if (! $response->successful()) { - return back()->with('error', 'Unable to fetch youtube url'); - } - - $data = $response->json(); - - foreach ($data['items'] as $item) { - $videos[] = [ - 'title' => $item['snippet']['title'], - 'video_id' => $item['snippet']['resourceId']['videoId'], - 'position' => $item['snippet']['position'], - ]; - } - - $pageToken = $data['nextPageToken'] ?? null; - } while ($pageToken); - - if (! empty($validated['is_reversed'])) { - $videos = array_reverse($videos); - } - - // Apply naming strategy - foreach ($videos as $index => &$video) { - if ($validated['naming_strategy'] === 'youtube') { - $video['title'] = $video['title']; - } else { - $number = str_pad( - ($validated['start_number'] ?? 1) + $index, - 2, - '0', - STR_PAD_LEFT - ); - - $prefix = trim($validated['naming_prefix'] ?? ''); - $video['title'] = $prefix !== '' ? "{$prefix} - {$number}" : $number; - } - } - - $userId = Auth::id(); - $nodeId = (int) $validated['node_id']; - $videosCount = count($videos); - $isModerator = $request->user()->can('moderate resources'); - - if (! $isModerator) { - $this->ensureUnderPendingLimit($videosCount); - } - - DB::transaction(function () use ($videos, $userId, $nodeId, $isModerator) { - foreach ($videos as $video) { - $finalUrl = "https://www.youtube.com/watch?v={$video['video_id']}"; - - if ($isModerator) { - Resource::create([ - 'user_id' => $userId, - 'node_id' => $nodeId, - 'title' => $video['title'], - 'resource_type' => 'video', - 'external_url' => $finalUrl, - ]); - } else { - ResourceChangeRequest::recordCreate($userId, $nodeId, [ - 'title' => $video['title'], - 'resource_type' => 'video', - 'external_url' => $finalUrl, - ]); - } - } - }); - - $message = $isModerator - ? 'YouTube playlist imported successfully.' - : 'YouTube playlist imported and submitted for moderation.'; - - return back()->with('success', $message); - } - public function bulkRename(Request $request, Node $node) { if ($node->isEffectivelyFrozen()) { diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php index 9ac54df7..e7199ea9 100644 --- a/app/Http/Controllers/NodeController.php +++ b/app/Http/Controllers/NodeController.php @@ -65,6 +65,7 @@ public function show(Subject $subject, $path) 'id' => $node->id, 'name' => $node->name, 'slug' => $node->slug, + 'is_frozen' => $node->isEffectivelyFrozen(), ], 'nodes' => $nodes, 'breadcrumb' => $node->breadcrumb(), diff --git a/app/Http/Controllers/ResourceController.php b/app/Http/Controllers/ResourceController.php index e4649c31..1d1e4c65 100644 --- a/app/Http/Controllers/ResourceController.php +++ b/app/Http/Controllers/ResourceController.php @@ -2,10 +2,18 @@ namespace App\Http\Controllers; +use App\Http\Requests\Resource\BulkImageStoreRequest; +use App\Http\Requests\Resource\BulkVideoStoreRequest; +use App\Http\Requests\Resource\StoreResourceRequest; use App\Models\Node; use App\Models\Resource; +use App\Models\ResourceChangeRequest; use App\Models\ResourceCompletion; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Http; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; class ResourceController extends Controller @@ -71,4 +79,227 @@ public function toggleComplete(Resource $resource) return back(); } + + /** + * Determine maximum allowed pending submissions for the user. + * Verified users: 100, Unverified users: 30. + */ + public function getMaxPendingSubmissions(): int + { + return Auth::user()?->is_verified ? 100 : 30; + } + + /** + * Check if user is eligible to submit (account age) and within pending change requests quota. + */ + public function ensureCanSubmit(int $incomingCount = 1): void + { + $user = Auth::user(); + if ($user?->can('moderate resources')) { + return; + } + + if ($user?->created_at && $user->created_at->diffInHours(now()) < 24) { + throw ValidationException::withMessages([ + 'account_age' => 'আপনার অ্যাকাউন্টটি নতুন। স্প্যাম রোধে অ্যাকাউন্ট তৈরির ২৪ ঘণ্টা পর রিসোর্স আপলোড করা যাবে।', + ]); + } + + $userId = Auth::id(); + $maxLimit = $this->getMaxPendingSubmissions(); + + $currentPending = ResourceChangeRequest::where('user_id', $userId) + ->where('status', 'pending') + ->count(); + + if (($currentPending + $incomingCount) > $maxLimit) { + throw ValidationException::withMessages([ + 'pending_limit' => "আপনি সর্বোচ্চ {$maxLimit}টি কন্টেন্ট আপলোড করার অনুরোধ করতে পারেন। আপনার আপলোডকৃত {$currentPending}টি কন্টেন্ট বর্তমানে পর্যালোচনাধীন রয়েছে, তাই অনুগ্রহ করে অপেক্ষা করুন। ", + ]); + } + } + + public function store(StoreResourceRequest $request) + { + $validated = $request->validated(); + $isModerator = $request->user()->can('moderate resources'); + + if (! $isModerator) { + $this->ensureCanSubmit(1); + } + + if ($request->hasFile('file')) { + $validated['file_path'] = $request->file('file')->store("resources/{$validated['resource_type']}s"); + } + + if ($isModerator) { + $validated['user_id'] = Auth::id(); + Resource::create($validated); + + return back()->with('success', 'Resource created successfully.'); + } + + ResourceChangeRequest::recordCreate( + Auth::id(), + (int) $validated['node_id'], + $validated + ); + + return redirect()->route('submissions.index')->with('success', 'Resource submitted for moderation.'); + } + + public function storeBulkImages(BulkImageStoreRequest $request) + { + $validated = $request->validated(); + $filesCount = count($request->file('files') ?? []); + $isModerator = $request->user()->can('moderate resources'); + + if (! $isModerator) { + $this->ensureCanSubmit($filesCount); + } + + $userId = Auth::id(); + $nodeId = (int) $validated['node_id']; + + DB::transaction(function () use ($request, $validated, $userId, $nodeId, $isModerator) { + foreach ($request->file('files') as $index => $file) { + $filePath = $file->store('resources/images'); + $title = $validated['custom_titles'][$index]; + + if ($isModerator) { + Resource::create([ + 'user_id' => $userId, + 'node_id' => $nodeId, + 'title' => $title, + 'resource_type' => 'image', + 'file_path' => $filePath, + ]); + } else { + ResourceChangeRequest::recordCreate($userId, $nodeId, [ + 'title' => $title, + 'resource_type' => 'image', + 'file_path' => $filePath, + ]); + } + } + }); + + if ($isModerator) { + return back()->with('success', 'Images uploaded successfully.'); + } + + return redirect()->route('submissions.index')->with('success', 'Images submitted for moderation.'); + } + + public function storeBulkVideos(BulkVideoStoreRequest $request) + { + $validated = $request->validated(); + $isModerator = $request->user()->can('moderate resources'); + + if (! $isModerator) { + $this->ensureCanSubmit(1); + } + + $playlistUrl = $validated['playlist_url']; + + // Extract playlist ID + parse_str(parse_url($playlistUrl, PHP_URL_QUERY), $query); + + $playlistId = $query['list'] ?? null; + + if (! $playlistId) { + return back()->with('error', 'Invalid Youtube URL'); + } + + $videos = []; + $pageToken = null; + + do { + $response = Http::get('https://www.googleapis.com/youtube/v3/playlistItems', [ + 'part' => 'snippet', + 'playlistId' => $playlistId, + 'maxResults' => 50, + 'pageToken' => $pageToken, + 'key' => config('services.youtube.key'), + ]); + + if (! $response->successful()) { + return back()->with('error', 'Unable to fetch youtube url'); + } + + $data = $response->json(); + + foreach ($data['items'] as $item) { + $videos[] = [ + 'title' => $item['snippet']['title'], + 'video_id' => $item['snippet']['resourceId']['videoId'], + 'position' => $item['snippet']['position'], + ]; + } + + if (! $isModerator && count($videos) >= $this->getMaxPendingSubmissions()) { + break; + } + + $pageToken = $data['nextPageToken'] ?? null; + } while ($pageToken); + + if (! empty($validated['is_reversed'])) { + $videos = array_reverse($videos); + } + + // Apply naming strategy + foreach ($videos as $index => &$video) { + if ($validated['naming_strategy'] === 'youtube') { + $video['title'] = $video['title']; + } else { + $number = str_pad( + ($validated['start_number'] ?? 1) + $index, + 2, + '0', + STR_PAD_LEFT + ); + + $prefix = trim($validated['naming_prefix'] ?? ''); + $video['title'] = $prefix !== '' ? "{$prefix} - {$number}" : $number; + } + } + + $userId = Auth::id(); + $nodeId = (int) $validated['node_id']; + $videosCount = count($videos); + $isModerator = $request->user()->can('moderate resources'); + + if (! $isModerator) { + $this->ensureCanSubmit($videosCount); + } + + DB::transaction(function () use ($videos, $userId, $nodeId, $isModerator) { + foreach ($videos as $video) { + $finalUrl = "https://www.youtube.com/watch?v={$video['video_id']}"; + + if ($isModerator) { + Resource::create([ + 'user_id' => $userId, + 'node_id' => $nodeId, + 'title' => $video['title'], + 'resource_type' => 'video', + 'external_url' => $finalUrl, + ]); + } else { + ResourceChangeRequest::recordCreate($userId, $nodeId, [ + 'title' => $video['title'], + 'resource_type' => 'video', + 'external_url' => $finalUrl, + ]); + } + } + }); + + if ($isModerator) { + return back()->with('success', 'YouTube playlist imported successfully.'); + } + + return redirect()->route('submissions.index')->with('success', 'YouTube playlist imported and submitted for moderation.'); + } } diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php new file mode 100644 index 00000000..59517998 --- /dev/null +++ b/app/Http/Controllers/SubmissionController.php @@ -0,0 +1,58 @@ +is_verified ? 100 : 30; + $counts = ResourceChangeRequest::where('user_id', $user->id) + ->selectRaw('status, count(*) as total') + ->groupBy('status') + ->pluck('total', 'status'); + + $pendingCount = (int) $counts->get('pending', 0); + $approvedCount = (int) $counts->get('approved', 0); + $rejectedCount = (int) $counts->get('rejected', 0); + $totalCount = (int) $counts->sum(); + + $status = $request->query('status'); + $query = ResourceChangeRequest::where('user_id', $user->id) + ->with(['resource', 'node.subject']) + ->latest(); + + if ($status && in_array($status, ['pending', 'approved', 'rejected'])) { + $query->where('status', $status); + } + + $submissions = $query->paginate(15)->withQueryString(); + + $submissions->getCollection()->each(function ($submission) { + $submission->node?->append('breadcrumb'); + }); + + return Inertia::render('Submissions/Index', [ + 'submissions' => $submissions, + 'quota' => [ + 'used' => $pendingCount, + 'max' => $maxLimit, + 'is_verified' => (bool) $user->is_verified, + ], + 'counts' => [ + 'all' => $totalCount, + 'pending' => $pendingCount, + 'approved' => $approvedCount, + 'rejected' => $rejectedCount, + ], + 'currentStatus' => $status ?? 'all', + ]); + } +} diff --git a/app/Notifications/ResourceModerationNotification.php b/app/Notifications/ResourceModerationNotification.php index 4266c5d6..a3e72bfa 100644 --- a/app/Notifications/ResourceModerationNotification.php +++ b/app/Notifications/ResourceModerationNotification.php @@ -35,14 +35,9 @@ public function via(object $notifiable): array */ public function toArray(object $notifiable): array { - $node = $this->changeRequest->node; - if ($node && $node->subject) { - $path = implode('/', array_column($node->breadcrumb(), 'slug')); - $hash = $this->status === 'rejected' ? '#rejected' : '#live'; - $url = "/admin/subjects/{$node->subject->slug}/nodes/{$path}{$hash}"; - } else { - $url = '/admin'; - } + $url = $this->status === 'rejected' + ? '/my-submissions?status=rejected' + : '/my-submissions?status=approved'; if ($this->totalCount > 1) { $title = $this->status === 'rejected' diff --git a/database/migrations/2026_10_13_160000_remove_create_resources_permission.php b/database/migrations/2026_10_13_160000_remove_create_resources_permission.php new file mode 100644 index 00000000..e69193f1 --- /dev/null +++ b/database/migrations/2026_10_13_160000_remove_create_resources_permission.php @@ -0,0 +1,23 @@ +delete(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Permission::findOrCreate('create resources', 'web'); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 9017d5a4..85e9ad9e 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -46,7 +46,6 @@ public function run(): void /* * Resource management */ - Permission::findOrCreate('create resources'); Permission::findOrCreate('edit resources'); Permission::findOrCreate('delete resources'); Permission::findOrCreate('moderate resources'); diff --git a/resources/js/components/EmptyState.vue b/resources/js/components/EmptyState.vue index fc37ccc8..c8027f96 100644 --- a/resources/js/components/EmptyState.vue +++ b/resources/js/components/EmptyState.vue @@ -1,19 +1,13 @@ - - diff --git a/resources/js/components/admin/ReviewResourceModal.vue b/resources/js/components/admin/ReviewResourceModal.vue new file mode 100644 index 00000000..116e5fef --- /dev/null +++ b/resources/js/components/admin/ReviewResourceModal.vue @@ -0,0 +1,711 @@ + + + diff --git a/resources/js/components/navigation/Navigation.tsx b/resources/js/components/navigation/Navigation.tsx index f58db1e6..31bc0064 100644 --- a/resources/js/components/navigation/Navigation.tsx +++ b/resources/js/components/navigation/Navigation.tsx @@ -396,6 +396,31 @@ export const SiteRail = defineComponent({ )} + {user.value && ( + + + + Submissions + + + )} ) : ( /* Expanded: Standard 280px Guide */ @@ -493,6 +518,33 @@ export const SiteRail = defineComponent({ )} )} + {user.value && ( + + + + My Submissions + + {isActive('/my-submissions') && ( + + )} + + )} {/* Platform & Community Section */}
@@ -1262,6 +1314,24 @@ export const SiteDrawer = defineComponent({ My Profile + {/* My Submissions */} + { + isProfileMenuOpen.value = false; + }} + class="flex items-center gap-2.5 rounded-xl px-2.5 py-2 text-xs font-semibold text-slate-700 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white" + > + + + My Submissions + + + {/* 2. Account Settings */} )} + {user.value && ( + + + + My Submissions + + {isActive( + '/my-submissions', + ) && ( + + )} + + )} {/* Platform & Community Links */} diff --git a/resources/js/components/resources/FolderLockedModal.vue b/resources/js/components/resources/FolderLockedModal.vue new file mode 100644 index 00000000..a9b4ea01 --- /dev/null +++ b/resources/js/components/resources/FolderLockedModal.vue @@ -0,0 +1,50 @@ + + + diff --git a/resources/js/components/resources/UploadResourceDropdown.vue b/resources/js/components/resources/UploadResourceDropdown.vue new file mode 100644 index 00000000..18caf1fd --- /dev/null +++ b/resources/js/components/resources/UploadResourceDropdown.vue @@ -0,0 +1,151 @@ + + + diff --git a/resources/js/pages/Node.vue b/resources/js/pages/Node.vue index 7524a8e9..8de3a297 100644 --- a/resources/js/pages/Node.vue +++ b/resources/js/pages/Node.vue @@ -8,6 +8,7 @@ import BreadcrumbNav from '@/components/BreadcrumbNav.vue'; import EmptyState from '@/components/EmptyState.vue'; import NodeRow from '@/components/NodeRow.vue'; import ResourceRow from '@/components/ResourceRow.vue'; +import UploadResourceDropdown from '@/components/resources/UploadResourceDropdown.vue'; import UserListItem from '@/components/UserListItem.vue'; import { useAuth } from '@/lib/useAuth'; @@ -384,10 +385,14 @@ const handleVote = (type: 'up' | 'down') => { class="flex flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900" >
Name - +
@@ -407,7 +412,6 @@ const handleVote = (type: 'up' | 'down') => { v-else title="কোনো রিসোর্স পাওয়া যায়নি" description="শীঘ্রই এখানে নতুন স্টাডি ম্যাটেরিয়াল ও নোট আপলোড করা হবে।" - :show-cta="true" />
diff --git a/resources/js/pages/Submissions/Index.vue b/resources/js/pages/Submissions/Index.vue new file mode 100644 index 00000000..4fe47483 --- /dev/null +++ b/resources/js/pages/Submissions/Index.vue @@ -0,0 +1,423 @@ + + + diff --git a/resources/js/pages/admin/Index.vue b/resources/js/pages/admin/Index.vue index 0c2d3ea5..043ef94e 100644 --- a/resources/js/pages/admin/Index.vue +++ b/resources/js/pages/admin/Index.vue @@ -147,7 +147,6 @@ const getFilterUrl = (course: string, group: string) => { v-else title="No subjects found" description="No subjects have been created in this category yet." - :show-cta="false" /> diff --git a/resources/js/pages/admin/Node.vue b/resources/js/pages/admin/Node.vue index f39aeebb..4d7f7cf1 100644 --- a/resources/js/pages/admin/Node.vue +++ b/resources/js/pages/admin/Node.vue @@ -1,10 +1,8 @@