Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/Http/Controllers/Admin/ResourceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
use App\Http\Requests\Resource\BulkVideoStoreRequest;
use App\Http\Requests\Resource\StoreResourceRequest;
use App\Http\Requests\Resource\UpdateResourceRequest;
use App\Models\Node;
use App\Models\Resource;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
Expand Down Expand Up @@ -160,4 +162,28 @@ public function storeBulkVideos(BulkVideoStoreRequest $request)

return back()->with('success', 'YouTube playlist imported successfully.');
}

public function bulkRename(Request $request, Node $node)
{
$validated = $request->validate([
'prefix' => ['required', 'string', 'max:100'],
'start_number' => ['nullable', 'integer', 'min:0'],
]);

$rawPrefix = trim($validated['prefix']);
$cleanPrefix = rtrim($rawPrefix, ' -');
$startNumber = (int) ($validated['start_number'] ?? 1);

$resources = $node->resources()->orderBy('id')->get();

DB::transaction(function () use ($resources, $cleanPrefix, $startNumber) {
foreach ($resources as $index => $resource) {
$number = str_pad((string) ($startNumber + $index), 2, '0', STR_PAD_LEFT);
$title = $cleanPrefix !== '' ? "{$cleanPrefix} - {$number}" : $number;
$resource->update(['title' => $title]);
}
});

return back()->with('success', "Renamed {$resources->count()} resources successfully.");
}
}
227 changes: 227 additions & 0 deletions resources/js/components/admin/BulkRenameModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { PencilLine, Loader2, AlertCircle } from 'lucide-vue-next';
import { ref, computed, watch } from 'vue';
import BaseModal from '@/components/BaseModal.vue';

const props = defineProps<{
isOpen: boolean;
node: {
id: number;
name: string;
};
resourcesCount: number;
}>();

const emit = defineEmits<{
(e: 'close'): void;
}>();

const prefix = ref('Class');
const startNumber = ref(1);
const isSaving = ref(false);
const errorMessage = ref('');

const cleanPrefix = computed(() => {
return prefix.value.replace(/\s*-\s*$/, '').trim();
});

const previews = computed(() => {
const start = Number(startNumber.value) || 1;
const count = Math.min(props.resourcesCount || 3, 3);
const items = [];

for (let i = 0; i < count; i++) {
const num = String(start + i).padStart(2, '0');
const title =
cleanPrefix.value !== '' ? `${cleanPrefix.value} - ${num}` : num;
items.push(title);
}

return items;
});

const handleClose = () => {
if (isSaving.value) {
return;
}

errorMessage.value = '';
emit('close');
};

watch(
() => props.isOpen,
(open) => {
if (open) {
errorMessage.value = '';
isSaving.value = false;
}
},
);

const handleSubmit = () => {
if (isSaving.value || props.resourcesCount === 0) {
return;
}

isSaving.value = true;
errorMessage.value = '';

router.post(
`/admin/nodes/${props.node.id}/resources/bulk-rename`,
{
prefix: prefix.value,
start_number: startNumber.value,
},
{
preserveScroll: true,
onSuccess: () => {
handleClose();
},
onError: (errors) => {
errorMessage.value =
errors.prefix ||
errors.start_number ||
'Failed to rename resources.';
},
onFinish: () => {
isSaving.value = false;
},
},
);
};
</script>

<template>
<BaseModal
:is-open="isOpen"
title="Bulk Rename Resources"
description="Quickly rename all resources in this folder with a sequential prefix."
max-width="md"
@close="handleClose"
>
<template #icon>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400"
>
<PencilLine class="h-5 w-5" />
</div>
</template>

<form @submit.prevent="handleSubmit">
<div class="space-y-4 p-4 sm:p-6">
<!-- Error Alert -->
<div
v-if="errorMessage"
class="flex items-center gap-2 rounded-xl border border-red-200 bg-red-50 p-3 text-xs text-red-600 dark:border-red-900/50 dark:bg-red-950/50 dark:text-red-400"
>
<AlertCircle class="h-4 w-4 shrink-0" />
<span>{{ errorMessage }}</span>
</div>

<!-- Prefix Input -->
<div class="space-y-1.5">
<label
for="rename_prefix"
class="block text-xs font-semibold text-slate-700 dark:text-gray-300"
>
Title Prefix
</label>
<input
id="rename_prefix"
v-model="prefix"
type="text"
placeholder="e.g. Class, Lecture, Chapter"
class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500"
required
/>
<p class="text-[11px] text-slate-500 dark:text-gray-400">
Will be formatted as:
<span class="font-semibold">{{
cleanPrefix ? `${cleanPrefix} - 01` : '01'
}}</span>
</p>
</div>

<!-- Start Number Input -->
<div class="space-y-1.5">
<label
for="start_number"
class="block text-xs font-semibold text-slate-700 dark:text-gray-300"
>
Starting Number
</label>
<input
id="start_number"
v-model.number="startNumber"
type="number"
min="0"
class="w-full rounded-lg border border-slate-300 bg-white px-3.5 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500"
required
/>
</div>

<!-- Live Preview Card -->
<div
class="rounded-xl border border-slate-100 bg-slate-50/70 p-3 dark:border-gray-800 dark:bg-gray-800/40"
>
<span
class="mb-1.5 block text-[11px] font-bold tracking-wider text-slate-500 uppercase dark:text-gray-400"
>
Preview (First {{ previews.length }} items)
</span>
<div
class="space-y-1 font-mono text-xs text-slate-700 dark:text-gray-300"
>
<div
v-for="(item, idx) in previews"
:key="idx"
class="flex items-center gap-2"
>
<span class="text-slate-400 dark:text-gray-500"
>{{ idx + 1 }}.</span
>
<span
class="font-semibold text-indigo-600 dark:text-indigo-400"
>{{ item }}</span
>
</div>
<div
v-if="resourcesCount > 3"
class="text-[11px] text-slate-400 dark:text-gray-500"
>
... and {{ resourcesCount - 3 }} more
</div>
</div>
</div>
</div>

<!-- Footer -->
<div
class="flex items-center justify-end gap-2 border-t border-slate-100 bg-slate-50/60 px-4 py-3 sm:px-6 dark:border-gray-800 dark:bg-gray-900/60"
>
<button
type="button"
@click="handleClose"
class="cursor-pointer rounded-lg border border-slate-200 bg-white px-3.5 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>

<button
type="submit"
:disabled="isSaving || resourcesCount === 0 || !cleanPrefix"
class="inline-flex cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-indigo-600 px-4 py-2 text-xs font-semibold text-white shadow-2xs transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Loader2 v-if="isSaving" class="h-3.5 w-3.5 animate-spin" />
<span>{{
isSaving
? 'Renaming...'
: `Rename All (${resourcesCount})`
}}</span>
</button>
</div>
</form>
</BaseModal>
</template>
29 changes: 28 additions & 1 deletion resources/js/pages/admin/Node.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
<script setup lang="ts">
import { Head, Link, usePage } from '@inertiajs/vue3';
import { Plus, FolderPlus, ArrowLeft, ChevronDown } from 'lucide-vue-next';
import {
Plus,
FolderPlus,
ArrowLeft,
ChevronDown,
PencilLine,
} from 'lucide-vue-next';
import { computed, ref, onMounted, onUnmounted } from 'vue';
import BulkImageModal from '@/components/admin/BulkImageModal.vue';
import BulkNodeModal from '@/components/admin/BulkNodeModal.vue';
import BulkRenameModal from '@/components/admin/BulkRenameModal.vue';
import BulkVideoModal from '@/components/admin/BulkVideoModal.vue';
import CreateNodeModal from '@/components/admin/CreateNodeModal.vue';
import CreateResourceModal from '@/components/admin/CreateResourceModal.vue';
Expand All @@ -29,6 +36,7 @@ const folderDropdownRef = ref<HTMLElement | null>(null);
const isBulkModalOpen = ref(false);
const isBulkImageModalOpen = ref(false);
const isBulkVideoModalOpen = ref(false);
const isBulkRenameModalOpen = ref(false);
const isSingleModalOpen = ref(false);
const editingNode = ref<any | null>(null);
const isSingleResourceModalOpen = ref(false);
Expand Down Expand Up @@ -285,12 +293,31 @@ onUnmounted(() => document.removeEventListener('click', closeDropdowns));
@close="isBulkVideoModalOpen = false"
/>

<!-- Bulk Rename Modal -->
<BulkRenameModal
v-if="parent"
:is-open="isBulkRenameModalOpen"
:node="parent"
:resources-count="resources?.length ?? 0"
@close="isBulkRenameModalOpen = false"
/>

<div class="flex flex-1 flex-col">
<template v-if="totalItemsCount > 0">
<div
class="flex shrink-0 items-center justify-between rounded-t-lg border-b border-gray-100 bg-gray-50/70 px-4 py-2.5 text-xs font-semibold tracking-wider text-gray-400 uppercase dark:border-gray-700 dark:bg-gray-800/70 dark:text-gray-500"
>
<span>Resources</span>

<button
v-if="can('edit resources') && resources?.length"
type="button"
@click="isBulkRenameModalOpen = true"
class="inline-flex cursor-pointer items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium text-indigo-600 transition-colors hover:bg-indigo-50 hover:text-indigo-700 dark:text-indigo-400 dark:hover:bg-indigo-950/50 dark:hover:text-indigo-300"
>
<PencilLine class="h-3.5 w-3.5" />
<span>Bulk Rename</span>
</button>
</div>

<div class="flex flex-col gap-2.5 sm:gap-3">
Expand Down
1 change: 1 addition & 0 deletions routes/admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@

Route::middleware('permission:edit resources')->group(function () {
Route::post('/resources/{resource}/patch', [AdminResourceController::class, 'update']);
Route::post('/nodes/{node}/resources/bulk-rename', [AdminResourceController::class, 'bulkRename'])->name('resources.bulk-rename');
});

Route::delete('/resources/{resource}', [AdminResourceController::class, 'destroy'])->middleware('permission:delete resources');
Expand Down
Loading