diff --git a/app/Console/Commands/GenerateSitemap.php b/app/Console/Commands/GenerateSitemap.php
index a9d6549d..068be6ae 100644
--- a/app/Console/Commands/GenerateSitemap.php
+++ b/app/Console/Commands/GenerateSitemap.php
@@ -48,7 +48,7 @@ public function handle(): int
}
// 2. Subjects
- Subject::orderBy('name')->get()->each(function ($subject) use ($sitemap) {
+ Subject::where('is_disabled', false)->orderBy('name')->get()->each(function ($subject) use ($sitemap) {
$sitemap->add(
Url::create(url($subject->slug))
->setLastModificationDate($subject->updated_at)
diff --git a/app/Http/Controllers/NodeController.php b/app/Http/Controllers/NodeController.php
index e7199ea9..d3ce1ee1 100644
--- a/app/Http/Controllers/NodeController.php
+++ b/app/Http/Controllers/NodeController.php
@@ -13,6 +13,10 @@ class NodeController extends Controller
{
public function show(Subject $subject, $path)
{
+ if ($subject->is_disabled) {
+ abort(404);
+ }
+
$slugs = explode('/', trim($path, '/'));
$node = null;
diff --git a/app/Http/Controllers/ResourceController.php b/app/Http/Controllers/ResourceController.php
index 0eea9a30..98f0f460 100644
--- a/app/Http/Controllers/ResourceController.php
+++ b/app/Http/Controllers/ResourceController.php
@@ -169,6 +169,8 @@ public function storeBulkImages(BulkImageStoreRequest $request)
$nodeId = (int) $validated['node_id'];
DB::transaction(function () use ($request, $validated, $userId, $nodeId, $isModerator) {
+ $content = $validated['content'] ?? null;
+
foreach ($request->file('files') as $index => $file) {
$filePath = $file->store('resources/images');
$title = $validated['custom_titles'][$index];
@@ -178,12 +180,14 @@ public function storeBulkImages(BulkImageStoreRequest $request)
'user_id' => $userId,
'node_id' => $nodeId,
'title' => $title,
+ 'content' => $content,
'resource_type' => 'image',
'file_path' => $filePath,
]);
} else {
ResourceChangeRequest::recordCreate($userId, $nodeId, [
'title' => $title,
+ 'content' => $content,
'resource_type' => 'image',
'file_path' => $filePath,
]);
diff --git a/app/Http/Controllers/SubjectController.php b/app/Http/Controllers/SubjectController.php
index a3164b75..a4a1cd84 100644
--- a/app/Http/Controllers/SubjectController.php
+++ b/app/Http/Controllers/SubjectController.php
@@ -27,7 +27,8 @@ public function index(Request $request, $course)
}
$subjects = Cache::rememberForever("home_page_subjects_{$course}_{$group}", function () use ($course, $group) {
- return Subject::orderBy('sort_order', 'asc')
+ return Subject::where('is_disabled', false)
+ ->orderBy('sort_order', 'asc')
->where('course', $course)
->whereIn('group', [$group, 'common'])
->withCount([
@@ -96,6 +97,10 @@ public function index(Request $request, $course)
public function show(Subject $subject)
{
+ if ($subject->is_disabled) {
+ abort(404);
+ }
+
$nodes = Cache::rememberForever("subject_page_{$subject->id}", function () use ($subject) {
return Node::where('subject_id', $subject->id)
->whereNull('parent_id')
diff --git a/app/Http/Requests/Resource/BulkImageStoreRequest.php b/app/Http/Requests/Resource/BulkImageStoreRequest.php
index 08c2357a..f539f7ae 100644
--- a/app/Http/Requests/Resource/BulkImageStoreRequest.php
+++ b/app/Http/Requests/Resource/BulkImageStoreRequest.php
@@ -31,6 +31,7 @@ public function rules(): array
'custom_titles.*' => 'required|string|max:100',
'files' => 'required|array|min:1|max:20',
'files.*' => 'required|image|mimes:jpg,jpeg,png,webp|max:5120',
+ 'content' => 'nullable|string',
];
}
}
diff --git a/app/Http/Requests/Subject/StoreSubjectRequest.php b/app/Http/Requests/Subject/StoreSubjectRequest.php
index 320d15e4..29dc6388 100644
--- a/app/Http/Requests/Subject/StoreSubjectRequest.php
+++ b/app/Http/Requests/Subject/StoreSubjectRequest.php
@@ -44,6 +44,8 @@ public function rules(): array
'course' => ['required', 'string', 'in:ssc,hsc'],
'group' => ['sometimes', 'string', 'in:science,humanities,commerce,common'],
'is_trackable' => ['sometimes', 'boolean'],
+ 'is_optional' => ['sometimes', 'boolean'],
+ 'is_disabled' => ['sometimes', 'boolean'],
];
}
}
diff --git a/app/Http/Requests/Subject/UpdateSubjectRequest.php b/app/Http/Requests/Subject/UpdateSubjectRequest.php
index bd7b6afc..fc27775d 100644
--- a/app/Http/Requests/Subject/UpdateSubjectRequest.php
+++ b/app/Http/Requests/Subject/UpdateSubjectRequest.php
@@ -44,6 +44,8 @@ public function rules(): array
'course' => ['sometimes', 'string', 'in:ssc,hsc'],
'group' => ['sometimes', 'string', 'in:science,humanities,commerce,common'],
'is_trackable' => ['sometimes', 'boolean'],
+ 'is_optional' => ['sometimes', 'boolean'],
+ 'is_disabled' => ['sometimes', 'boolean'],
];
}
}
diff --git a/app/Models/Subject.php b/app/Models/Subject.php
index 41691ec6..3858704a 100644
--- a/app/Models/Subject.php
+++ b/app/Models/Subject.php
@@ -13,6 +13,7 @@ protected function casts(): array
return [
'is_trackable' => 'boolean',
'is_optional' => 'boolean',
+ 'is_disabled' => 'boolean',
];
}
diff --git a/database/migrations/2026_10_17_132746_add_is_disabled_to_subjects_table.php b/database/migrations/2026_10_17_132746_add_is_disabled_to_subjects_table.php
new file mode 100644
index 00000000..e31bf6e7
--- /dev/null
+++ b/database/migrations/2026_10_17_132746_add_is_disabled_to_subjects_table.php
@@ -0,0 +1,32 @@
+boolean('is_disabled')->default(false)->after('is_trackable');
+ }
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('subjects', function (Blueprint $table) {
+ if (Schema::hasColumn('subjects', 'is_disabled')) {
+ $table->dropColumn('is_disabled');
+ }
+ });
+ }
+};
diff --git a/resources/js/components/admin/BulkImageModal.vue b/resources/js/components/admin/BulkImageModal.vue
index 715f768a..07903f34 100644
--- a/resources/js/components/admin/BulkImageModal.vue
+++ b/resources/js/components/admin/BulkImageModal.vue
@@ -8,6 +8,7 @@ import {
Loader2,
AlertCircle,
X,
+ ChevronDown,
} from 'lucide-vue-next';
import { ref, computed, watch, onUnmounted } from 'vue';
import BaseModal from '@/components/BaseModal.vue';
@@ -38,6 +39,8 @@ const isProcessingServer = ref(false);
const namingStrategy = ref<'serial' | 'original'>('serial');
const namingPrefix = ref('image');
const startNumber = ref(1);
+const showMoreOptions = ref(false);
+const content = ref('');
const MAX_IMAGES = 20;
@@ -138,6 +141,8 @@ const clearAll = () => {
compressionStatusText.value = '';
uploadProgress.value = null;
isProcessingServer.value = false;
+ content.value = '';
+ showMoreOptions.value = false;
};
const handleClose = () => {
@@ -218,6 +223,7 @@ const submitForm = async () => {
start_number: startNumber.value,
files: filesToUpload,
custom_titles: processedTitles.value,
+ content: content.value.trim() ? content.value.trim() : null,
};
router.post('/resources/bulk/images', payload, {
@@ -465,6 +471,44 @@ const submitForm = async () => {
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/admin/CreateSubjectModal.vue b/resources/js/components/admin/CreateSubjectModal.vue
index b9484aa7..a26433dd 100644
--- a/resources/js/components/admin/CreateSubjectModal.vue
+++ b/resources/js/components/admin/CreateSubjectModal.vue
@@ -12,20 +12,30 @@ import BaseModal from '@/components/BaseModal.vue';
import SubjectIcon from '@/components/SubjectIcon.vue';
import { SUBJECT_ICON_LIST } from '@/lib/subjectIcons';
-const props = defineProps<{
- isOpen: boolean;
- subject?: {
- id: number;
- name: string;
- slug: string;
- course: string;
- group?: string;
- tailwind_format: string;
- icon: string;
- sort_order: number;
- is_trackable?: boolean;
- } | null;
-}>();
+const props = withDefaults(
+ defineProps<{
+ isOpen: boolean;
+ subject?: {
+ id: number;
+ name: string;
+ slug: string;
+ course: string;
+ group?: string;
+ tailwind_format: string;
+ icon: string;
+ sort_order: number;
+ is_trackable?: boolean;
+ is_optional?: boolean;
+ is_disabled?: boolean;
+ } | null;
+ defaultCourse?: string;
+ defaultGroup?: string;
+ }>(),
+ {
+ defaultCourse: 'hsc',
+ defaultGroup: 'science',
+ },
+);
const emit = defineEmits<{
(e: 'close'): void;
@@ -58,41 +68,44 @@ const tailwindFormat = ref('bg-indigo-50 text-indigo-600');
const icon = ref('BookOpen');
const sortOrder = ref(0);
const isTrackable = ref(false);
+const isOptional = ref(false);
+const isDisabled = ref(false);
const initForm = () => {
if (props.subject) {
name.value = props.subject.name || '';
slug.value = props.subject.slug || '';
- course.value = props.subject.course || 'hsc';
- group.value = props.subject.group || 'science';
+ course.value = props.subject.course || props.defaultCourse || 'hsc';
+ group.value = props.subject.group || props.defaultGroup || 'science';
tailwindFormat.value =
props.subject.tailwind_format || 'bg-indigo-50 text-indigo-600';
icon.value = props.subject.icon || 'BookOpen';
sortOrder.value = props.subject.sort_order ?? 0;
isTrackable.value = Boolean(props.subject.is_trackable);
+ isOptional.value = Boolean(props.subject.is_optional);
+ isDisabled.value = Boolean(props.subject.is_disabled);
} else {
name.value = '';
slug.value = '';
- course.value = 'hsc';
- group.value = 'science';
+ course.value = props.defaultCourse || 'hsc';
+ group.value = props.defaultGroup || 'science';
tailwindFormat.value = 'bg-indigo-50 text-indigo-600';
icon.value = 'BookOpen';
sortOrder.value = 0;
isTrackable.value = false;
+ isOptional.value = false;
+ isDisabled.value = false;
}
errorMessage.value = '';
showAdvanced.value = false;
};
-watch(
- () => props.isOpen,
- (open) => {
- if (open) {
- initForm();
- }
- },
-);
+watch([() => props.isOpen, () => props.subject], ([open]) => {
+ if (open) {
+ initForm();
+ }
+});
const handleClose = () => {
if (isSaving.value) {
@@ -119,6 +132,8 @@ const submitForm = () => {
icon: icon.value,
sort_order: sortOrder.value,
is_trackable: isTrackable.value,
+ is_optional: isOptional.value,
+ is_disabled: isDisabled.value,
};
if (props.subject) {
@@ -234,7 +249,7 @@ const submitForm = () => {
for="subject_group"
class="block text-xs font-bold text-slate-700 dark:text-gray-300"
>
- Group (বিভাগ)
+ Subject Type / Group (বিষয় প্রকার / বিভাগ)
*
@@ -433,6 +451,59 @@ const submitForm = () => {
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/admin/SubjectCard.vue b/resources/js/components/admin/SubjectCard.vue
index 11d90a11..87646bdf 100644
--- a/resources/js/components/admin/SubjectCard.vue
+++ b/resources/js/components/admin/SubjectCard.vue
@@ -48,12 +48,26 @@ const handleDelete = () => {
{{ subject.name }}
+
+ Optional
+
+
Trackable
+
+
+ Disabled
+
diff --git a/resources/js/pages/Resource.vue b/resources/js/pages/Resource.vue
index cb19f36d..30f6d0dd 100644
--- a/resources/js/pages/Resource.vue
+++ b/resources/js/pages/Resource.vue
@@ -515,6 +515,21 @@ const toggleFullscreen = () => {
>.
+
+
+
+
Note:
+
+ {{ resource.content }}
+
+
diff --git a/resources/js/pages/admin/Index.vue b/resources/js/pages/admin/Index.vue
index 043ef94e..921c532e 100644
--- a/resources/js/pages/admin/Index.vue
+++ b/resources/js/pages/admin/Index.vue
@@ -70,6 +70,8 @@ const getFilterUrl = (course: string, group: string) => {
diff --git a/tests/Feature/PublicResourceUploadTest.php b/tests/Feature/PublicResourceUploadTest.php
index 9ae987be..5eb8e6b2 100644
--- a/tests/Feature/PublicResourceUploadTest.php
+++ b/tests/Feature/PublicResourceUploadTest.php
@@ -252,6 +252,7 @@ function createTestSubjectAndNode(bool $isFrozen = false): array
'node_id' => $node->id,
'files' => [$file1, $file2],
'custom_titles' => ['Image Page 1', 'Image Page 2'],
+ 'content' => 'Shared note for these images',
])
->assertRedirect(route('submissions.index'))
->assertSessionHas('success', 'Images submitted for moderation.');
@@ -259,6 +260,32 @@ function createTestSubjectAndNode(bool $isFrozen = false): array
expect(Resource::count())->toBe(0);
expect(ResourceChangeRequest::count())->toBe(2);
expect(ResourceChangeRequest::pluck('status')->all())->toBe(['pending', 'pending']);
+ expect(ResourceChangeRequest::first()->payload['content'])->toBe('Shared note for these images');
+});
+
+test('bulk image upload with content directly creates resources for moderator', function () {
+ [, $node] = createTestSubjectAndNode();
+
+ $moderator = User::factory()->create();
+ $moderator->givePermissionTo('moderate resources');
+
+ $file1 = UploadedFile::fake()->create('page1.png', 100, 'image/png');
+ $file2 = UploadedFile::fake()->create('page2.png', 100, 'image/png');
+
+ $this->actingAs($moderator)
+ ->post('/resources/bulk/images', [
+ 'node_id' => $node->id,
+ 'files' => [$file1, $file2],
+ 'custom_titles' => ['Image Page 1', 'Image Page 2'],
+ 'content' => 'Batch lecture slides note',
+ ])
+ ->assertSessionHas('success', 'Images uploaded successfully.');
+
+ expect(Resource::count())->toBe(2);
+ expect(Resource::pluck('content')->all())->toBe([
+ 'Batch lecture slides note',
+ 'Batch lecture slides note',
+ ]);
});
test('my-submissions page returns list and quota', function () {
diff --git a/tests/Feature/SubjectManagementTest.php b/tests/Feature/SubjectManagementTest.php
index f8edc63a..f84dcf7e 100644
--- a/tests/Feature/SubjectManagementTest.php
+++ b/tests/Feature/SubjectManagementTest.php
@@ -1,5 +1,6 @@
'science',
]);
});
+
+test('admin can toggle disable on a subject', function () {
+ $admin = adminUserWithPermissions(['view admin', 'create subjects', 'edit subjects']);
+
+ $response = $this->actingAs($admin)->post('/admin/subjects', [
+ 'name' => 'Disabled Subject',
+ 'course' => 'hsc',
+ 'group' => 'science',
+ 'tailwind_format' => 'bg-slate-500',
+ 'icon' => 'book-open',
+ 'sort_order' => 1,
+ 'is_disabled' => true,
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('subjects', [
+ 'name' => 'Disabled Subject',
+ 'is_disabled' => 1,
+ ]);
+
+ $subject = Subject::where('name', 'Disabled Subject')->first();
+
+ $response = $this->actingAs($admin)->patch("/admin/subjects/edit/{$subject->id}", [
+ 'is_disabled' => false,
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('subjects', [
+ 'id' => $subject->id,
+ 'is_disabled' => 0,
+ ]);
+});
+
+test('disabled subjects are hidden from home page', function () {
+ $this->withoutVite();
+
+ $enabledSubject = Subject::create([
+ 'name' => 'Enabled Subject',
+ 'slug' => 'enabled-subject',
+ 'course' => 'hsc',
+ 'group' => 'science',
+ 'tailwind_format' => 'bg-slate-500',
+ 'icon' => 'book-open',
+ 'sort_order' => 1,
+ 'is_disabled' => false,
+ ]);
+
+ $disabledSubject = Subject::create([
+ 'name' => 'Disabled Subject',
+ 'slug' => 'disabled-subject',
+ 'course' => 'hsc',
+ 'group' => 'science',
+ 'tailwind_format' => 'bg-slate-500',
+ 'icon' => 'book-open',
+ 'sort_order' => 2,
+ 'is_disabled' => true,
+ ]);
+
+ $response = $this->get('/?group=science');
+ $response->assertOk();
+
+ $response->assertInertia(fn ($page) => $page
+ ->component('Home')
+ ->where('subjects', fn ($subjects) => collect($subjects)->pluck('id')->contains($enabledSubject->id)
+ && ! collect($subjects)->pluck('id')->contains($disabledSubject->id)
+ )
+ );
+});
+
+test('disabled subjects return 404 for everyone including admins', function () {
+ $this->withoutVite();
+
+ $disabledSubject = Subject::create([
+ 'name' => 'Hidden Subject',
+ 'slug' => 'hidden-subject',
+ 'course' => 'hsc',
+ 'group' => 'science',
+ 'tailwind_format' => 'bg-slate-500',
+ 'icon' => 'book-open',
+ 'sort_order' => 1,
+ 'is_disabled' => true,
+ ]);
+
+ $chapter = Node::create([
+ 'subject_id' => $disabledSubject->id,
+ 'name' => 'Chapter 1',
+ 'slug' => 'chapter-1',
+ ]);
+
+ // Guest / Regular user
+ $this->get('/hidden-subject')
+ ->assertInertia(fn ($page) => $page->component('errors/404'));
+
+ $this->get('/hidden-subject/chapter-1')
+ ->assertInertia(fn ($page) => $page->component('errors/404'));
+
+ // Admin user with permissions also gets 404
+ $admin = adminUserWithPermissions(['edit subjects']);
+ $this->actingAs($admin)
+ ->get('/hidden-subject')
+ ->assertInertia(fn ($page) => $page->component('errors/404'));
+
+ $this->actingAs($admin)
+ ->get('/hidden-subject/chapter-1')
+ ->assertInertia(fn ($page) => $page->component('errors/404'));
+});
+
+test('admin can define and edit subject type group and optional status', function () {
+ $admin = adminUserWithPermissions(['view admin', 'create subjects', 'edit subjects']);
+
+ // Create a common subject
+ $response = $this->actingAs($admin)->post('/admin/subjects', [
+ 'name' => 'Bangla 1st Paper',
+ 'course' => 'hsc',
+ 'group' => 'common',
+ 'tailwind_format' => 'bg-emerald-50 text-emerald-600',
+ 'icon' => 'BookOpen',
+ 'sort_order' => 1,
+ 'is_optional' => false,
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('subjects', [
+ 'name' => 'Bangla 1st Paper',
+ 'group' => 'common',
+ 'is_optional' => false,
+ ]);
+
+ $subject = Subject::where('name', 'Bangla 1st Paper')->first();
+
+ // Edit to humanities (arts) and mark as optional
+ $response = $this->actingAs($admin)->patch("/admin/subjects/edit/{$subject->id}", [
+ 'group' => 'humanities',
+ 'is_optional' => true,
+ ]);
+
+ $response->assertRedirect();
+ $this->assertDatabaseHas('subjects', [
+ 'id' => $subject->id,
+ 'group' => 'humanities',
+ 'is_optional' => true,
+ ]);
+});