develop-updateui #1
@@ -43,10 +43,10 @@ const links = [
|
|||||||
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" @click="i.action && i.action($event)"
|
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" @click="i.action && i.action($event)"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
i.className,
|
i.className,
|
||||||
($route.path === i.href || i.isActive?.value) && 'bg-primary/15'
|
($route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value) && 'bg-primary/15'
|
||||||
)">
|
)">
|
||||||
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
||||||
:filled="$route.path === i.href || i.isActive?.value" />
|
:filled="$route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value" />
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -319,3 +319,12 @@ export const fetchMockVideos = async ({ page, limit, searchQuery, status }: Fetc
|
|||||||
total
|
total
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
export const fetchMockVideoById = async (id: string) => {
|
||||||
|
// Simulate API delay
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
const video = mockVideos.find(v => v.id === id);
|
||||||
|
if (!video) {
|
||||||
|
throw new Error('Video not found');
|
||||||
|
}
|
||||||
|
return video;
|
||||||
|
};
|
||||||
@@ -102,29 +102,34 @@ const routes: RouteData[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "video",
|
path: "video",
|
||||||
name: "video",
|
children: [
|
||||||
component: () => import("./video/Videos.vue"),
|
{
|
||||||
meta: {
|
path: "",
|
||||||
head: {
|
name: "video",
|
||||||
title: "Videos - Holistream",
|
component: () => import("./video/Videos.vue"),
|
||||||
meta: [
|
meta: {
|
||||||
{
|
head: {
|
||||||
name: "description",
|
title: "Videos - Holistream",
|
||||||
content: "Manage your video content.",
|
meta: [
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
content: "Manage your video content.",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
},
|
path: ":id",
|
||||||
{
|
name: "video-detail",
|
||||||
path: "video/:id/edit",
|
component: () => import("./video/DetailVideo.vue"),
|
||||||
name: "video-edit",
|
meta: {
|
||||||
component: () => import("./video/EditVideo.vue"),
|
head: {
|
||||||
meta: {
|
title: "Edit Video - Holistream",
|
||||||
head: {
|
},
|
||||||
title: "Edit Video - Holistream",
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "payments-and-plans",
|
path: "payments-and-plans",
|
||||||
@@ -174,17 +179,17 @@ const routes: RouteData[] = [
|
|||||||
];
|
];
|
||||||
const createAppRouter = () => {
|
const createAppRouter = () => {
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: import.meta.env.SSR
|
history: import.meta.env.SSR
|
||||||
? createMemoryHistory() // server
|
? createMemoryHistory() // server
|
||||||
: createWebHistory(), // client
|
: createWebHistory(), // client
|
||||||
routes,
|
routes,
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(to, from, savedPosition) {
|
||||||
if (savedPosition) {
|
if (savedPosition) {
|
||||||
return savedPosition
|
return savedPosition;
|
||||||
}
|
}
|
||||||
return { top: 0 }
|
return { top: 0 };
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import InputText from 'primevue/inputtext';
|
|||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Skeleton from 'primevue/skeleton';
|
import Skeleton from 'primevue/skeleton';
|
||||||
|
import { fetchMockVideoById } from '@/mocks/videos';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -26,9 +27,9 @@ const form = ref({
|
|||||||
const fetchVideo = async () => {
|
const fetchVideo = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await client.videos.videosDetail(videoId);
|
const videoData = await fetchMockVideoById(videoId);
|
||||||
// response is HttpResponse, response.data is the body, response.data.data is the ModelVideo
|
// response is HttpResponse, response.data is the body, response.data.data is the ModelVideo
|
||||||
const videoData = response.data.data;
|
// const videoData = response.data.data;
|
||||||
if (videoData) {
|
if (videoData) {
|
||||||
video.value = videoData;
|
video.value = videoData;
|
||||||
form.value.title = videoData.title || '';
|
form.value.title = videoData.title || '';
|
||||||
@@ -76,7 +77,7 @@ onMounted(() => {
|
|||||||
{ label: 'Videos', to: '/video' },
|
{ label: 'Videos', to: '/video' },
|
||||||
{ label: 'Edit' }
|
{ label: 'Edit' }
|
||||||
]" />
|
]" />
|
||||||
|
<div class="max-w-6xl mx-auto mt-6">
|
||||||
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||||
<Skeleton width="100%" height="2rem" />
|
<Skeleton width="100%" height="2rem" />
|
||||||
<Skeleton width="100%" height="10rem" />
|
<Skeleton width="100%" height="10rem" />
|
||||||
@@ -122,5 +123,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,7 +22,7 @@ const iconHoist = createStaticVNode(`<svg xmlns="http://www.w3.org/2000/svg" cla
|
|||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const limit = ref(100);
|
const limit = ref(10);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
@@ -122,98 +122,75 @@ watch([searchQuery, selectedStatus, limit, page], () => {
|
|||||||
{ label: 'Dashboard', to: '/' },
|
{ label: 'Dashboard', to: '/' },
|
||||||
{ label: 'Videos' }
|
{ label: 'Videos' }
|
||||||
]" :actions="[
|
]" :actions="[
|
||||||
{
|
{
|
||||||
label: 'Upload Video',
|
label: 'Upload Video',
|
||||||
icon: iconHoist,
|
icon: iconHoist,
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
onClick: () => router.push('/upload')
|
onClick: () => router.push('/upload')
|
||||||
}
|
}
|
||||||
]" />
|
]" />
|
||||||
|
|
||||||
<VideoBulkActions :selectedVideos="selectedVideos" @delete="deleteSelectedVideos" @clear="selectedVideos = []" />
|
<VideoBulkActions :selectedVideos="selectedVideos" @delete="deleteSelectedVideos" @clear="selectedVideos = []" />
|
||||||
<VideoFilters v-model:searchQuery="searchQuery" v-model:selectedStatus="selectedStatus" v-model:viewMode="viewMode"
|
<VideoFilters :loading="loading" v-model:searchQuery="searchQuery" :selectedStatus="selectedStatus" v-model:viewMode="viewMode"
|
||||||
v-model:page="page" v-model:limit="limit" :total="total" ref="videoFilters" :statusOptions="statusOptions"
|
v-model:page="page" v-model:limit="limit" :total="total" ref="videoFilters" :statusOptions="statusOptions"
|
||||||
@search="handleSearch" @filter="handleFilter" />
|
@search="handleSearch" @filter="handleFilter" />
|
||||||
|
|
||||||
|
<Transition name="fade" mode="out-in">
|
||||||
|
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="loading" class="animate-pulse">
|
<div v-if="loading" class="animate-pulse">
|
||||||
<!-- Grid Skeleton -->
|
<!-- Grid Skeleton -->
|
||||||
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
<div v-if="viewMode === 'grid'" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
<div v-for="i in 8" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
<div v-for="i in 8" :key="i" class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||||
<Skeleton height="150px" width="100%"></Skeleton>
|
<Skeleton height="150px" width="100%"></Skeleton>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton>
|
<Skeleton width="80%" height="1.5rem" class="mb-2"></Skeleton>
|
||||||
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton>
|
<Skeleton width="60%" height="1rem" class="mb-4"></Skeleton>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<Skeleton width="3rem" height="1rem"></Skeleton>
|
<Skeleton width="3rem" height="1rem"></Skeleton>
|
||||||
<Skeleton width="3rem" height="1rem"></Skeleton>
|
<Skeleton width="3rem" height="1rem"></Skeleton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Table Skeleton -->
|
||||||
|
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
||||||
|
<div class="flex gap-4 items-center">
|
||||||
|
<Skeleton width="5rem" height="3rem" class="rounded"></Skeleton>
|
||||||
|
<div class="flex-1">
|
||||||
|
<Skeleton width="40%" height="1.2rem" class="mb-2"></Skeleton>
|
||||||
|
<Skeleton width="30%" height="1rem"></Skeleton>
|
||||||
|
</div>
|
||||||
|
<Skeleton width="10%" height="1rem"></Skeleton>
|
||||||
|
<Skeleton width="10%" height="1rem"></Skeleton>
|
||||||
|
<Skeleton width="5rem" height="2rem" borderRadius="16px"></Skeleton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Table Skeleton -->
|
|
||||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
<!-- Error State -->
|
||||||
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||||||
<div class="flex gap-4 items-center">
|
<span class="i-heroicons-exclamation-circle text-red-500 text-4xl mb-3 inline-block" />
|
||||||
<Skeleton width="5rem" height="3rem" class="rounded"></Skeleton>
|
<p class="text-red-700 font-medium">{{ error }}</p>
|
||||||
<div class="flex-1">
|
<button @click="fetchVideos"
|
||||||
<Skeleton width="40%" height="1.2rem" class="mb-2"></Skeleton>
|
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
||||||
<Skeleton width="30%" height="1rem"></Skeleton>
|
Try Again
|
||||||
</div>
|
</button>
|
||||||
<Skeleton width="10%" height="1rem"></Skeleton>
|
|
||||||
<Skeleton width="10%" height="1rem"></Skeleton>
|
|
||||||
<Skeleton width="5rem" height="2rem" borderRadius="16px"></Skeleton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Empty State -->
|
||||||
<div v-else-if="error" class="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
<EmptyState v-else-if="videos.length === 0" title="No videos found"
|
||||||
<span class="i-heroicons-exclamation-circle text-red-500 text-4xl mb-3 inline-block" />
|
description="You haven't uploaded any videos yet. Start by uploading your first video!"
|
||||||
<p class="text-red-700 font-medium">{{ error }}</p>
|
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
|
||||||
<button @click="fetchVideos"
|
:onAction="() => router.push('/upload')" />
|
||||||
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
|
<!-- Grid View -->
|
||||||
Try Again
|
<VideoGrid :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo"
|
||||||
</button>
|
v-else-if="viewMode === 'grid'" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Table View -->
|
||||||
<EmptyState v-else-if="videos.length === 0" title="No videos found"
|
<VideoTable v-else :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
|
||||||
description="You haven't uploaded any videos yet. Start by uploading your first video!"
|
</Transition>
|
||||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
|
|
||||||
:onAction="() => router.push('/upload')" />
|
|
||||||
|
|
||||||
<!-- Grid View -->
|
|
||||||
<div v-else-if="viewMode === 'grid'">
|
|
||||||
<VideoGrid :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
|
|
||||||
|
|
||||||
<!-- Grid Pagination (was manually inside grid container in original, but now grid component only has items) -->
|
|
||||||
<!-- Wait, VideoGrid.vue template only had the grid. Pagination was missing in Grid View in original file? -->
|
|
||||||
<!-- Checking Step 193... Line 462 Pagination was inside the "Table View" div (v-else). -->
|
|
||||||
<!-- But line 333 (Grid View) ended at line 386. -->
|
|
||||||
<!-- The pagination (lines 462-480) was INSIDE the v-else block for Table view. -->
|
|
||||||
<!-- So Grid View did NOT have pagination? That seems like a bug or oversight in original. -->
|
|
||||||
<!-- Or maybe pagination was intended for both but placed inside table wrapper. -->
|
|
||||||
<!-- I should probably add pagination to Grid View too, or place it outside both. -->
|
|
||||||
|
|
||||||
<!-- For now, I will add pagination controls here for Grid view too if needed, or better: -->
|
|
||||||
<!-- VideoTable has pagination built-in. VideoGrid does not. -->
|
|
||||||
<!-- I should probably extract Pagination to a component too? -->
|
|
||||||
<!-- Or just use PrimeVue Paginator? -->
|
|
||||||
<!-- Given the request is to split components, I'll stick to what was there. -->
|
|
||||||
<!-- If Grid View didn't have pagination visible, I won't add it unless I'm sure. -->
|
|
||||||
<!-- Actually, typically both views share pagination. The original code had pagination nested in table view. -->
|
|
||||||
<!-- I will pull pagination out of VideoTable and put it in Videos.vue so it's shared? -->
|
|
||||||
<!-- OR I will leave it as is: Grid View has no pagination? That implies infinite scroll or just showing all? -->
|
|
||||||
<!-- Fetch says limit=20. So pagination is needed. -->
|
|
||||||
<!-- I'll add common pagination below the view. -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table View -->
|
|
||||||
<div v-else>
|
|
||||||
<VideoTable :videos="videos" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineEmits, defineProps } from 'vue';
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
selectedStatus: string;
|
selectedStatus: string;
|
||||||
@@ -8,6 +7,7 @@ defineProps<{
|
|||||||
total: number;
|
total: number;
|
||||||
page: number; // 1-based index
|
page: number; // 1-based index
|
||||||
limit: number;
|
limit: number;
|
||||||
|
loading: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import TrashIcon from '@/components/icons/TrashIcon.vue';
|
|||||||
import VideoIcon from '@/components/icons/VideoIcon.vue';
|
import VideoIcon from '@/components/icons/VideoIcon.vue';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
import { defineEmits, defineProps } from 'vue';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
videos: ModelVideo[];
|
videos: ModelVideo[];
|
||||||
@@ -84,7 +83,7 @@ const emit = defineEmits<{
|
|||||||
<LinkIcon class="w-4 h-4" />
|
<LinkIcon class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<div class="w-px h-3 bg-gray-200 mx-1"></div>
|
<div class="w-px h-3 bg-gray-200 mx-1"></div>
|
||||||
<router-link :to="{ name: 'video-edit', params: { id: data.id } }"
|
<router-link :to="{ name: 'video-detail', params: { id: data.id } }"
|
||||||
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors inline-block"
|
class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors inline-block"
|
||||||
title="Edit">
|
title="Edit">
|
||||||
<PencilIcon class="w-4 h-4" />
|
<PencilIcon class="w-4 h-4" />
|
||||||
|
|||||||
Reference in New Issue
Block a user