init
This commit is contained in:
@@ -76,7 +76,7 @@ const routes: RouteData[] = [
|
||||
{
|
||||
path: "upload",
|
||||
name: "upload",
|
||||
component: () => import("./add/Add.vue"),
|
||||
component: () => import("./upload/Upload.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Upload - Holistream',
|
||||
@@ -86,7 +86,7 @@ const routes: RouteData[] = [
|
||||
{
|
||||
path: "video",
|
||||
name: "video",
|
||||
component: () => import("./add/Add.vue"),
|
||||
component: () => import("./video/Videos.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Videos - Holistream',
|
||||
@@ -99,7 +99,7 @@ const routes: RouteData[] = [
|
||||
{
|
||||
path: "plans",
|
||||
name: "plans",
|
||||
component: () => import("./add/Add.vue"),
|
||||
component: () => import("./plans/Plans.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Plans & Billing',
|
||||
@@ -130,28 +130,28 @@ const routes: RouteData[] = [
|
||||
},
|
||||
];
|
||||
const createAppRouter = () => {
|
||||
const router = createRouter({
|
||||
history: import.meta.env.SSR
|
||||
? createMemoryHistory() // server
|
||||
: createWebHistory(), // client
|
||||
routes,
|
||||
});
|
||||
const router = createRouter({
|
||||
history: import.meta.env.SSR
|
||||
? createMemoryHistory() // server
|
||||
: createWebHistory(), // client
|
||||
routes,
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const auth = useAuthStore();
|
||||
const head = inject(headSymbol);
|
||||
(head as any).push(to.meta.head || {});
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!auth.user) {
|
||||
next({ name: "login" });
|
||||
router.beforeEach((to, from, next) => {
|
||||
const auth = useAuthStore();
|
||||
const head = inject(headSymbol);
|
||||
(head as any).push(to.meta.head || {});
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!auth.user) {
|
||||
next({ name: "login" });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
return router;
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
||||
export default createAppRouter;
|
||||
|
||||
117
src/routes/plans/Plans.vue
Normal file
117
src/routes/plans/Plans.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-3xl font-bold mb-6">Choose Your Plan</h1>
|
||||
<div v-if="loading" class="flex justify-center">
|
||||
<div class="i-svg-spinners-180-ring-with-bg text-4xl"></div>
|
||||
</div>
|
||||
<div v-else-if="error" class="text-red-500">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div v-for="plan in plans" :key="plan.id" class="border rounded-lg p-6 shadow-md bg-white dark:bg-gray-800 flex flex-col">
|
||||
<h2 class="text-xl font-semibold mb-2">{{ plan.name }}</h2>
|
||||
<div class="text-3xl font-bold mb-4">${{ plan.price }}<span class="text-sm font-normal text-gray-500">/{{ plan.cycle }}</span></div>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6 flex-grow">{{ plan.description }}</p>
|
||||
|
||||
<ul class="mb-6 space-y-2">
|
||||
<li class="flex items-center">
|
||||
<span class="i-heroicons-check-circle text-green-500 mr-2"></span>
|
||||
<span>Storage: {{ formatBytes(plan.storage_limit || 0) }}</span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="i-heroicons-check-circle text-green-500 mr-2"></span>
|
||||
<span>Max Duration: {{ formatDuration(plan.duration_limit || 0) }}</span>
|
||||
</li>
|
||||
<li class="flex items-center">
|
||||
<span class="i-heroicons-check-circle text-green-500 mr-2"></span>
|
||||
<span>Uploads: {{ plan.upload_limit }} / day</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
@click="subscribe(plan)"
|
||||
class="w-full py-2 px-4 bg-primary-600 hover:bg-primary-700 text-white rounded transition-colors disabled:opacity-50"
|
||||
:disabled="subscribing === plan.id"
|
||||
>
|
||||
<span v-if="subscribing === plan.id" class="i-svg-spinners-180-ring-with-bg mr-2"></span>
|
||||
{{ subscribing === plan.id ? 'Processing...' : 'Subscribe' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
|
||||
const plans = ref<ModelPlan[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const subscribing = ref<string | null>(null);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await client.plans.plansList();
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
plans.value = response.data;
|
||||
} else {
|
||||
// Fallback or handle unexpected structure?
|
||||
// Based on client.ts it returns response.data as ModelPlan[] directly in the custom wrapper?
|
||||
// Wait, client.ts says: r.data = data. So if the response matches schema, it's inside data.
|
||||
// Let's re-read client.ts.
|
||||
// plansList defined as request<ResponseResponse & { data?: ModelPlan[] }>
|
||||
// So yes, response.data which is the body, and inside that, there is a data property.
|
||||
// wait, let's check client.ts generated code again.
|
||||
|
||||
// plansList returns Promise<HttpResponse<...>>
|
||||
// HttpResponse has .data property which IS the body.
|
||||
// The body type is ResponseResponse & { data?: ModelPlan[] }
|
||||
// So we access response.data.data
|
||||
plans.value = response.data.data || [];
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
error.value = err.message || 'Failed to load plans';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = async (plan: ModelPlan) => {
|
||||
if (!plan.id) return;
|
||||
subscribing.value = plan.id;
|
||||
try {
|
||||
// Mock payment for now as per plan, or call API if ready
|
||||
// client.payments.paymentsCreate({ amount: plan.price || 0, plan_id: plan.id });
|
||||
await client.payments.paymentsCreate({
|
||||
amount: plan.price || 0,
|
||||
plan_id: plan.id
|
||||
});
|
||||
alert(`Successfully subscribed to ${plan.name}`);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert('Failed to subscribe: ' + (err.message || 'Unknown error'));
|
||||
} finally {
|
||||
subscribing.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
return `${Math.floor(seconds / 60)} mins`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPlans();
|
||||
});
|
||||
</script>
|
||||
181
src/routes/upload/Upload.vue
Normal file
181
src/routes/upload/Upload.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="p-6 max-w-2xl mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-6">Upload Video</h1>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md">
|
||||
<div v-if="!file" class="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-10 text-center hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer" @drop.prevent="handleDrop" @dragover.prevent>
|
||||
<input type="file" id="fileInput" class="hidden" accept="video/*" @change="handleFileSelect">
|
||||
<label for="fileInput" class="cursor-pointer">
|
||||
<span class="i-heroicons-cloud-arrow-up text-6xl text-gray-400 mb-4 inline-block"></span>
|
||||
<p class="text-xl font-medium text-gray-700 dark:text-gray-200">Drag & drop your video here</p>
|
||||
<p class="text-sm text-gray-500 mt-2">or click to browse</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded">
|
||||
<div class="flex items-center space-x-3 overflow-hidden">
|
||||
<span class="i-heroicons-video-camera text-2xl text-primary-500 flex-shrink-0"></span>
|
||||
<span class="truncate font-medium">{{ file.name }}</span>
|
||||
<span class="text-xs text-gray-500 flex-shrink-0">({{ formatBytes(file.size) }})</span>
|
||||
</div>
|
||||
<button @click="resetFile" class="text-red-500 hover:text-red-700 flex-shrink-0" :disabled="uploading">
|
||||
<span class="i-heroicons-x-mark text-xl"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Title</label>
|
||||
<input v-model="form.title" type="text" class="w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 p-2" placeholder="My Awesome Video">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea v-model="form.description" class="w-full rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700 p-2" rows="3" placeholder="Describe your video..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadError" class="text-red-500 text-sm">
|
||||
{{ uploadError }}
|
||||
</div>
|
||||
|
||||
<div v-if="uploading" class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span>Uploading...</span>
|
||||
<span>{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-primary-600 transition-all duration-300" :style="{ width: `${uploadProgress}%` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="startUpload"
|
||||
class="w-full py-3 bg-primary-600 hover:bg-primary-700 text-white rounded font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="uploading || !form.title"
|
||||
>
|
||||
{{ uploading ? 'Uploading...' : 'Upload Video' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { client } from '@/api/client';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const file = ref<File | null>(null);
|
||||
const uploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const uploadError = ref<string | null>(null);
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files.length > 0) {
|
||||
file.value = target.files[0];
|
||||
form.title = file.value.name.replace(/\.[^/.]+$/, ""); // Default title from filename
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (event.dataTransfer?.files.length) {
|
||||
file.value = event.dataTransfer.files[0];
|
||||
form.title = file.value.name.replace(/\.[^/.]+$/, "");
|
||||
}
|
||||
};
|
||||
|
||||
const resetFile = () => {
|
||||
file.value = null;
|
||||
form.title = '';
|
||||
form.description = '';
|
||||
uploadError.value = null;
|
||||
uploadProgress.value = 0;
|
||||
};
|
||||
|
||||
const startUpload = async () => {
|
||||
if (!file.value || !form.title) return;
|
||||
|
||||
uploading.value = true;
|
||||
uploadError.value = null;
|
||||
uploadProgress.value = 0;
|
||||
|
||||
try {
|
||||
// 1. Get Presigned URL
|
||||
const presignedResponse = await client.videos.uploadUrlCreate({
|
||||
filename: file.value.name,
|
||||
content_type: file.value.type,
|
||||
size: file.value.size
|
||||
});
|
||||
|
||||
// Check for data in response
|
||||
const data = (presignedResponse.data as any).data || presignedResponse.data;
|
||||
if (!data || !data.url) {
|
||||
throw new Error('Failed to get upload URL');
|
||||
}
|
||||
|
||||
const uploadUrl = data.url;
|
||||
const finalVideoUrl = data.key || uploadUrl.split('?')[0]; // Assuming key is returned or can be derived
|
||||
|
||||
// 2. Upload file to S3 (using XMLHttpRequest for progress)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', uploadUrl, true);
|
||||
xhr.setRequestHeader('Content-Type', file.value!.type);
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadProgress.value = Math.round((e.loaded / e.total) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('Network error during upload'));
|
||||
xhr.send(file.value);
|
||||
});
|
||||
|
||||
// 3. Create Video Record
|
||||
await client.videos.videosCreate({
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
size: file.value.size,
|
||||
format: file.value.type,
|
||||
url: finalVideoUrl,
|
||||
// duration is optional, server might process it
|
||||
});
|
||||
|
||||
// Redirect to videos page
|
||||
router.push({ name: 'video' });
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
uploadError.value = err.message || 'Upload failed';
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
</script>
|
||||
103
src/routes/video/Videos.vue
Normal file
103
src/routes/video/Videos.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold">My Videos</h1>
|
||||
<router-link to="/upload" class="bg-primary-600 hover:bg-primary-700 text-white px-4 py-2 rounded transition-colors flex items-center">
|
||||
<span class="i-heroicons-cloud-arrow-up mr-2"></span>
|
||||
Upload Video
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center">
|
||||
<div class="i-svg-spinners-180-ring-with-bg text-4xl"></div>
|
||||
</div>
|
||||
<div v-else-if="error" class="text-red-500">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else-if="videos.length === 0" class="text-center text-gray-500 py-10">
|
||||
<p>No videos found. Upload your first video!</p>
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<div v-for="video in videos" :key="video.id" class="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow bg-white dark:bg-gray-800">
|
||||
<div class="aspect-video bg-gray-200 dark:bg-gray-700 relative group">
|
||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title" class="w-full h-full object-cover">
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-gray-400">
|
||||
<span class="i-heroicons-video-camera text-4xl"></span>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button class="text-white bg-primary-600 p-2 rounded-full hover:bg-primary-700" title="Play">
|
||||
<span class="i-heroicons-play-20-solid text-xl"></span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1 rounded">{{ formatDuration(video.duration || 0) }}</span>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-lg mb-1 truncate" :title="video.title">{{ video.title }}</h3>
|
||||
<p class="text-sm text-gray-500 mb-2 truncate">{{ video.description || 'No description' }}</p>
|
||||
<div class="flex justify-between items-center text-xs text-gray-400">
|
||||
<span>{{ formatDate(video.created_at) }}</span>
|
||||
<span :class="getStatusClass(video.status)">{{ video.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
|
||||
const videos = ref<ModelVideo[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await client.videos.videosList({ page: 1, limit: 50 });
|
||||
// Based on docs.json, schema might be incomplete, assuming standard response structure
|
||||
// response.data is the body. The body should have 'data' containing the list?
|
||||
const body = response.data as any; // Cast because generated type didn't have data field
|
||||
if (body.data && Array.isArray(body.data)) {
|
||||
videos.value = body.data;
|
||||
} else if (Array.isArray(body)) {
|
||||
videos.value = body;
|
||||
} else {
|
||||
console.warn('Unexpected video list format:', body);
|
||||
videos.value = [];
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
error.value = err.message || 'Failed to load videos';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
const getStatusClass = (status?: string) => {
|
||||
switch(status?.toLowerCase()) {
|
||||
case 'ready': return 'text-green-500';
|
||||
case 'processing': return 'text-yellow-500';
|
||||
case 'failed': return 'text-red-500';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchVideos();
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user