Compare commits
27 Commits
02247f9018
...
develop-ki
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f5bfc7a71 | |||
| c3a8e5b474 | |||
| cf9c488012 | |||
| 478c31defa | |||
| a9e5ea61f8 | |||
| 7a1f5d5ae0 | |||
| 6200ab7a1b | |||
| 4cc2cc0691 | |||
| fc86b3472e | |||
| 6c4015f8c4 | |||
| 820aa7a597 | |||
| b87d18576b | |||
| 58f2874102 | |||
| 8bdcbbf527 | |||
| 770c09b9b2 | |||
| ac74faadbe | |||
| 5ae0a15a30 | |||
| 476c0eb647 | |||
| 7d3d33ef7e | |||
| 55f467a10e | |||
| 1fe77f24dc | |||
| 21950753ab | |||
| c4244c1097 | |||
| f805bac0e6 | |||
| eed14fa0e5 | |||
| 9f521c76f4 | |||
| ae61ece0b0 |
98
components.d.ts
vendored
98
components.d.ts
vendored
@@ -13,60 +13,110 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
AddFilled: typeof import('./src/components/icons/AddFilled.vue')['default']
|
||||
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
Avatar: typeof import('./src/components/ui/form/Avatar.vue')['default']
|
||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
BellFilled: typeof import('./src/components/icons/BellFilled.vue')['default']
|
||||
Button: typeof import('primevue/button')['default']
|
||||
Checkbox: typeof import('primevue/checkbox')['default']
|
||||
Button: typeof import('./src/components/ui/form/Button.vue')['default']
|
||||
Card: typeof import('./src/components/ui/form/Card.vue')['default']
|
||||
Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
Checkbox: typeof import('./src/components/ui/form/Checkbox.vue')['default']
|
||||
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||
Dialog: typeof import('./src/components/ui/form/Dialog.vue')['default']
|
||||
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
Field: typeof import('./src/components/ui/form/Field.vue')['default']
|
||||
Form: typeof import('./src/components/ui/form/Form.vue')['default']
|
||||
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
HomeFilled: typeof import('./src/components/icons/HomeFilled.vue')['default']
|
||||
InputText: typeof import('primevue/inputtext')['default']
|
||||
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
Input: typeof import('./src/components/ui/form/Input.vue')['default']
|
||||
Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
LayoutFilled: typeof import('./src/components/icons/LayoutFilled.vue')['default']
|
||||
Message: typeof import('primevue/message')['default']
|
||||
Password: typeof import('primevue/password')['default']
|
||||
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
ProgressBar: typeof import('./src/components/ui/form/ProgressBar.vue')['default']
|
||||
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
Skeleton: typeof import('./src/components/ui/form/Skeleton.vue')['default']
|
||||
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
Table: typeof import('./src/components/ui/form/Table.vue')['default']
|
||||
Tag: typeof import('./src/components/ui/form/Tag.vue')['default']
|
||||
TanStackForm: typeof import('./src/components/ui/form/TanStackForm.vue')['default']
|
||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
Toast: typeof import('primevue/toast')['default']
|
||||
Textarea: typeof import('./src/components/ui/form/Textarea.vue')['default']
|
||||
Toast: typeof import('./src/components/ui/form/Toast.vue')['default']
|
||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default']
|
||||
Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
VideoFilled: typeof import('./src/components/icons/VideoFilled.vue')['default']
|
||||
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||
VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||
XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
// For TSX support
|
||||
declare global {
|
||||
const Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
const AddFilled: typeof import('./src/components/icons/AddFilled.vue')['default']
|
||||
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
const Avatar: typeof import('./src/components/ui/form/Avatar.vue')['default']
|
||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
const BellFilled: typeof import('./src/components/icons/BellFilled.vue')['default']
|
||||
const Button: typeof import('primevue/button')['default']
|
||||
const Checkbox: typeof import('primevue/checkbox')['default']
|
||||
const Button: typeof import('./src/components/ui/form/Button.vue')['default']
|
||||
const Card: typeof import('./src/components/ui/form/Card.vue')['default']
|
||||
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
const Checkbox: typeof import('./src/components/ui/form/Checkbox.vue')['default']
|
||||
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||
const Dialog: typeof import('./src/components/ui/form/Dialog.vue')['default']
|
||||
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
const Field: typeof import('./src/components/ui/form/Field.vue')['default']
|
||||
const Form: typeof import('./src/components/ui/form/Form.vue')['default']
|
||||
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
const Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
const HomeFilled: typeof import('./src/components/icons/HomeFilled.vue')['default']
|
||||
const InputText: typeof import('primevue/inputtext')['default']
|
||||
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
const Input: typeof import('./src/components/ui/form/Input.vue')['default']
|
||||
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
const LayoutFilled: typeof import('./src/components/icons/LayoutFilled.vue')['default']
|
||||
const Message: typeof import('primevue/message')['default']
|
||||
const Password: typeof import('primevue/password')['default']
|
||||
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
const ProgressBar: typeof import('./src/components/ui/form/ProgressBar.vue')['default']
|
||||
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
const Skeleton: typeof import('./src/components/ui/form/Skeleton.vue')['default']
|
||||
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
const Table: typeof import('./src/components/ui/form/Table.vue')['default']
|
||||
const Tag: typeof import('./src/components/ui/form/Tag.vue')['default']
|
||||
const TanStackForm: typeof import('./src/components/ui/form/TanStackForm.vue')['default']
|
||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
const Toast: typeof import('primevue/toast')['default']
|
||||
const Textarea: typeof import('./src/components/ui/form/Textarea.vue')['default']
|
||||
const Toast: typeof import('./src/components/ui/form/Toast.vue')['default']
|
||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
const UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default']
|
||||
const Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
const VideoFilled: typeof import('./src/components/icons/VideoFilled.vue')['default']
|
||||
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||
const VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||
const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
||||
}
|
||||
518
docs.json
Normal file
518
docs.json
Normal file
@@ -0,0 +1,518 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "This is the API server for Stream application.",
|
||||
"title": "Stream API",
|
||||
"termsOfService": "http://swagger.io/terms/",
|
||||
"contact": {
|
||||
"name": "API Support",
|
||||
"url": "http://www.swagger.io/support",
|
||||
"email": "support@swagger.io"
|
||||
},
|
||||
"license": {
|
||||
"name": "Apache 2.0",
|
||||
"url": "http://www.apache.org/licenses/LICENSE-2.0.html"
|
||||
},
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "localhost:8080",
|
||||
"basePath": "/",
|
||||
"paths": {
|
||||
"/payments": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Create a new payment",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"payment"
|
||||
],
|
||||
"summary": "Create Payment",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Payment Info",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/payment.CreatePaymentRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/plans": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all active plans",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"plan"
|
||||
],
|
||||
"summary": "List Plans",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/response.Response"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.Plan"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/videos": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get paginated videos",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"video"
|
||||
],
|
||||
"summary": "List Videos",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 1,
|
||||
"description": "Page number",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "Page size",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Create video record after upload",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"video"
|
||||
],
|
||||
"summary": "Create Video",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Video Info",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/video.CreateVideoRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/response.Response"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/model.Video"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/videos/upload-url": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Generate presigned URL for video upload",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"video"
|
||||
],
|
||||
"summary": "Get Upload URL",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "File Info",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/video.UploadURLRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/videos/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get video details by ID",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"video"
|
||||
],
|
||||
"summary": "Get Video",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Video ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/response.Response"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/model.Video"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/response.Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"model.Plan": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cycle": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"features": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"price": {
|
||||
"type": "number"
|
||||
},
|
||||
"quality_limit": {
|
||||
"type": "string"
|
||||
},
|
||||
"storage_limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"upload_limit": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.Video": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration": {
|
||||
"type": "integer"
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"hls_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"hls_token": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"processing_status": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"storage_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"thumbnail": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"views": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"payment.CreatePaymentRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"amount",
|
||||
"plan_id"
|
||||
],
|
||||
"properties": {
|
||||
"amount": {
|
||||
"type": "number"
|
||||
},
|
||||
"plan_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response.Response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer"
|
||||
},
|
||||
"data": {},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"video.CreateVideoRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"size",
|
||||
"title",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"duration": {
|
||||
"description": "Maybe client knows, or we process later",
|
||||
"type": "integer"
|
||||
},
|
||||
"format": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"description": "The S3 Key or Full URL",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"video.UploadURLRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"content_type",
|
||||
"filename",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"content_type": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"BearerAuth": {
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
package.json
50
package.json
@@ -6,41 +6,39 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy": "wrangler deploy",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
|
||||
"tail": "wrangler tail"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.946.0",
|
||||
"@aws-sdk/s3-presigned-post": "^3.946.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.946.0",
|
||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||
"@aws-sdk/client-s3": "^3.983.0",
|
||||
"@aws-sdk/s3-presigned-post": "^3.983.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.983.0",
|
||||
"@hiogawa/utils": "^1.7.0",
|
||||
"@primeuix/themes": "^2.0.2",
|
||||
"@primevue/forms": "^4.5.4",
|
||||
"@unhead/vue": "^2.1.1",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@pinia/colada": "^0.21.2",
|
||||
"@tanstack/vue-form": "^1.28.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unhead/vue": "^2.1.2",
|
||||
"@vueuse/core": "^14.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"firebase": "^12.8.0",
|
||||
"firebase-admin": "^13.6.0",
|
||||
"hono": "^4.11.3",
|
||||
"hono": "^4.11.7",
|
||||
"is-mobile": "^5.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"primevue": "^4.5.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4",
|
||||
"zod": "^4.3.2"
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^5.0.2",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.17.1",
|
||||
"@primevue/auto-import-resolver": "^4.5.4",
|
||||
"@types/node": "^25.0.3",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.3",
|
||||
"unocss": "^66.5.12",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"@cloudflare/vite-plugin": "^1.23.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||
"unocss": "^66.6.0",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-ssr-components": "^0.5.2",
|
||||
"wrangler": "^4.54.0"
|
||||
"wrangler": "^4.62.0"
|
||||
}
|
||||
}
|
||||
|
||||
688
src/api/client.ts
Normal file
688
src/api/client.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* ---------------------------------------------------------------
|
||||
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
|
||||
* ## ##
|
||||
* ## AUTHOR: acacode ##
|
||||
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
|
||||
* ---------------------------------------------------------------
|
||||
*/
|
||||
import { customFetch } from "@httpClientAdapter";
|
||||
export interface AuthForgotPasswordRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface AuthLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthRegisterRequest {
|
||||
email: string;
|
||||
/** @minLength 6 */
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AuthResetPasswordRequest {
|
||||
/** @minLength 6 */
|
||||
new_password: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface ModelPlan {
|
||||
cycle?: string;
|
||||
description?: string;
|
||||
duration_limit?: number;
|
||||
features?: string;
|
||||
id?: string;
|
||||
is_active?: boolean;
|
||||
name?: string;
|
||||
price?: number;
|
||||
quality_limit?: string;
|
||||
storage_limit?: number;
|
||||
upload_limit?: number;
|
||||
}
|
||||
|
||||
export interface ModelUser {
|
||||
avatar?: string;
|
||||
created_at?: string;
|
||||
email?: string;
|
||||
google_id?: string;
|
||||
id?: string;
|
||||
password?: string;
|
||||
plan_id?: string;
|
||||
role?: string;
|
||||
storage_used?: number;
|
||||
updated_at?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface ModelVideo {
|
||||
created_at?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
format?: string;
|
||||
hls_path?: string;
|
||||
hls_token?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
processing_status?: string;
|
||||
size?: number;
|
||||
status?: string;
|
||||
storage_type?: string;
|
||||
thumbnail?: string;
|
||||
title?: string;
|
||||
updated_at?: string;
|
||||
url?: string;
|
||||
user_id?: string;
|
||||
views?: number;
|
||||
}
|
||||
|
||||
export interface PaymentCreatePaymentRequest {
|
||||
amount: number;
|
||||
plan_id: string;
|
||||
}
|
||||
|
||||
export interface ResponseResponse {
|
||||
code?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface VideoCreateVideoRequest {
|
||||
description?: string;
|
||||
/** Maybe client knows, or we process later */
|
||||
duration?: number;
|
||||
format?: string;
|
||||
size: number;
|
||||
title: string;
|
||||
/** The S3 Key or Full URL */
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface VideoUploadURLRequest {
|
||||
content_type: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type QueryParamsType = Record<string | number, any>;
|
||||
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
|
||||
|
||||
export interface FullRequestParams extends Omit<RequestInit, "body"> {
|
||||
/** set parameter to `true` for call `securityWorker` for this request */
|
||||
secure?: boolean;
|
||||
/** request path */
|
||||
path: string;
|
||||
/** content type of request body */
|
||||
type?: ContentType;
|
||||
/** query params */
|
||||
query?: QueryParamsType;
|
||||
/** format of response (i.e. response.json() -> format: "json") */
|
||||
format?: ResponseFormat;
|
||||
/** request body */
|
||||
body?: unknown;
|
||||
/** base url */
|
||||
baseUrl?: string;
|
||||
/** request cancellation token */
|
||||
cancelToken?: CancelToken;
|
||||
}
|
||||
|
||||
export type RequestParams = Omit<
|
||||
FullRequestParams,
|
||||
"body" | "method" | "query" | "path"
|
||||
>;
|
||||
|
||||
export interface ApiConfig<SecurityDataType = unknown> {
|
||||
baseUrl?: string;
|
||||
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
|
||||
securityWorker?: (
|
||||
securityData: SecurityDataType | null,
|
||||
) => Promise<RequestParams | void> | RequestParams | void;
|
||||
customFetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface HttpResponse<D extends unknown, E extends unknown = unknown>
|
||||
extends Response {
|
||||
data: D;
|
||||
error: E;
|
||||
}
|
||||
|
||||
type CancelToken = Symbol | string | number;
|
||||
|
||||
export enum ContentType {
|
||||
Json = "application/json",
|
||||
JsonApi = "application/vnd.api+json",
|
||||
FormData = "multipart/form-data",
|
||||
UrlEncoded = "application/x-www-form-urlencoded",
|
||||
Text = "text/plain",
|
||||
}
|
||||
|
||||
export class HttpClient<SecurityDataType = unknown> {
|
||||
public baseUrl: string = "";
|
||||
private securityData: SecurityDataType | null = null;
|
||||
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
|
||||
private abortControllers = new Map<CancelToken, AbortController>();
|
||||
private customFetch = (...fetchParams: Parameters<typeof fetch>) =>
|
||||
fetch(...fetchParams);
|
||||
|
||||
private baseApiParams: RequestParams = {
|
||||
credentials: "same-origin",
|
||||
headers: {},
|
||||
redirect: "follow",
|
||||
referrerPolicy: "no-referrer",
|
||||
};
|
||||
|
||||
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
|
||||
Object.assign(this, apiConfig);
|
||||
}
|
||||
|
||||
public setSecurityData = (data: SecurityDataType | null) => {
|
||||
this.securityData = data;
|
||||
};
|
||||
|
||||
protected encodeQueryParam(key: string, value: any) {
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
|
||||
}
|
||||
|
||||
protected addQueryParam(query: QueryParamsType, key: string) {
|
||||
return this.encodeQueryParam(key, query[key]);
|
||||
}
|
||||
|
||||
protected addArrayQueryParam(query: QueryParamsType, key: string) {
|
||||
const value = query[key];
|
||||
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
|
||||
}
|
||||
|
||||
protected toQueryString(rawQuery?: QueryParamsType): string {
|
||||
const query = rawQuery || {};
|
||||
const keys = Object.keys(query).filter(
|
||||
(key) => "undefined" !== typeof query[key],
|
||||
);
|
||||
return keys
|
||||
.map((key) =>
|
||||
Array.isArray(query[key])
|
||||
? this.addArrayQueryParam(query, key)
|
||||
: this.addQueryParam(query, key),
|
||||
)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
protected addQueryParams(rawQuery?: QueryParamsType): string {
|
||||
const queryString = this.toQueryString(rawQuery);
|
||||
return queryString ? `?${queryString}` : "";
|
||||
}
|
||||
|
||||
private contentFormatters: Record<ContentType, (input: any) => any> = {
|
||||
[ContentType.Json]: (input: any) =>
|
||||
input !== null && (typeof input === "object" || typeof input === "string")
|
||||
? JSON.stringify(input)
|
||||
: input,
|
||||
[ContentType.JsonApi]: (input: any) =>
|
||||
input !== null && (typeof input === "object" || typeof input === "string")
|
||||
? JSON.stringify(input)
|
||||
: input,
|
||||
[ContentType.Text]: (input: any) =>
|
||||
input !== null && typeof input !== "string"
|
||||
? JSON.stringify(input)
|
||||
: input,
|
||||
[ContentType.FormData]: (input: any) => {
|
||||
if (input instanceof FormData) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return Object.keys(input || {}).reduce((formData, key) => {
|
||||
const property = input[key];
|
||||
formData.append(
|
||||
key,
|
||||
property instanceof Blob
|
||||
? property
|
||||
: typeof property === "object" && property !== null
|
||||
? JSON.stringify(property)
|
||||
: `${property}`,
|
||||
);
|
||||
return formData;
|
||||
}, new FormData());
|
||||
},
|
||||
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
|
||||
};
|
||||
|
||||
protected mergeRequestParams(
|
||||
params1: RequestParams,
|
||||
params2?: RequestParams,
|
||||
): RequestParams {
|
||||
return {
|
||||
...this.baseApiParams,
|
||||
...params1,
|
||||
...(params2 || {}),
|
||||
headers: {
|
||||
...(this.baseApiParams.headers || {}),
|
||||
...(params1.headers || {}),
|
||||
...((params2 && params2.headers) || {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected createAbortSignal = (
|
||||
cancelToken: CancelToken,
|
||||
): AbortSignal | undefined => {
|
||||
if (this.abortControllers.has(cancelToken)) {
|
||||
const abortController = this.abortControllers.get(cancelToken);
|
||||
if (abortController) {
|
||||
return abortController.signal;
|
||||
}
|
||||
return void 0;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(cancelToken, abortController);
|
||||
return abortController.signal;
|
||||
};
|
||||
|
||||
public abortRequest = (cancelToken: CancelToken) => {
|
||||
const abortController = this.abortControllers.get(cancelToken);
|
||||
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
this.abortControllers.delete(cancelToken);
|
||||
}
|
||||
};
|
||||
|
||||
public request = async <T = any, E = any>({
|
||||
body,
|
||||
secure,
|
||||
path,
|
||||
type,
|
||||
query,
|
||||
format,
|
||||
baseUrl,
|
||||
cancelToken,
|
||||
...params
|
||||
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
|
||||
const secureParams =
|
||||
((typeof secure === "boolean" ? secure : this.baseApiParams.secure) &&
|
||||
this.securityWorker &&
|
||||
(await this.securityWorker(this.securityData))) ||
|
||||
{};
|
||||
const requestParams = this.mergeRequestParams(params, secureParams);
|
||||
const queryString = query && this.toQueryString(query);
|
||||
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
|
||||
const responseFormat = format || requestParams.format;
|
||||
|
||||
return this.customFetch(
|
||||
`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`,
|
||||
{
|
||||
...requestParams,
|
||||
headers: {
|
||||
...(requestParams.headers || {}),
|
||||
...(type && type !== ContentType.FormData
|
||||
? { "Content-Type": type }
|
||||
: {}),
|
||||
},
|
||||
signal:
|
||||
(cancelToken
|
||||
? this.createAbortSignal(cancelToken)
|
||||
: requestParams.signal) || null,
|
||||
body:
|
||||
typeof body === "undefined" || body === null
|
||||
? null
|
||||
: payloadFormatter(body)
|
||||
},
|
||||
).then(async (response) => {
|
||||
const r = response as HttpResponse<T, E>;
|
||||
r.data = null as unknown as T;
|
||||
r.error = null as unknown as E;
|
||||
|
||||
const responseToParse = responseFormat ? response.clone() : response;
|
||||
const data = !responseFormat
|
||||
? r
|
||||
: await responseToParse[responseFormat]()
|
||||
.then((data) => {
|
||||
if (r.ok) {
|
||||
r.data = data;
|
||||
} else {
|
||||
r.error = data;
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.catch((e) => {
|
||||
r.error = e;
|
||||
return r;
|
||||
});
|
||||
|
||||
if (cancelToken) {
|
||||
this.abortControllers.delete(cancelToken);
|
||||
}
|
||||
|
||||
if (!response.ok) throw data;
|
||||
return data;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @title Stream API
|
||||
* @version 1.0
|
||||
* @license Apache 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html)
|
||||
* @termsOfService http://swagger.io/terms/
|
||||
* @contact API Support <support@swagger.io> (http://www.swagger.io/support)
|
||||
*
|
||||
* This is the API server for Stream application.
|
||||
*/
|
||||
export class Api<
|
||||
SecurityDataType extends unknown,
|
||||
> extends HttpClient<SecurityDataType> {
|
||||
auth = {
|
||||
/**
|
||||
* @description Request password reset link
|
||||
*
|
||||
* @tags auth
|
||||
* @name ForgotPasswordCreate
|
||||
* @summary Forgot Password
|
||||
* @request POST:/auth/forgot-password
|
||||
*/
|
||||
forgotPasswordCreate: (
|
||||
request: AuthForgotPasswordRequest,
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<ResponseResponse, ResponseResponse>({
|
||||
path: `/auth/forgot-password`,
|
||||
method: "POST",
|
||||
body: request,
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @description Callback for Google Login
|
||||
*
|
||||
* @tags auth
|
||||
* @name GoogleCallbackList
|
||||
* @summary Google Callback
|
||||
* @request GET:/auth/google/callback
|
||||
*/
|
||||
googleCallbackList: (params: RequestParams = {}) =>
|
||||
this.request<
|
||||
ResponseResponse & {
|
||||
data?: ModelUser;
|
||||
},
|
||||
ResponseResponse
|
||||
>({
|
||||
path: `/auth/google/callback`,
|
||||
method: "GET",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @description Redirect to Google for Login
|
||||
*
|
||||
* @tags auth
|
||||
* @name GoogleLoginList
|
||||
* @summary Google Login
|
||||
* @request GET:/auth/google/login
|
||||
*/
|
||||
googleLoginList: (params: RequestParams = {}) =>
|
||||
this.request<any, void>({
|
||||
path: `/auth/google/login`,
|
||||
method: "GET",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @description Login with email and password
|
||||
*
|
||||
* @tags auth
|
||||
* @name LoginCreate
|
||||
* @summary Login
|
||||
* @request POST:/auth/login
|
||||
*/
|
||||
loginCreate: (request: AuthLoginRequest, params: RequestParams = {}) =>
|
||||
this.request<
|
||||
ResponseResponse & {
|
||||
data?: ModelUser;
|
||||
},
|
||||
ResponseResponse
|
||||
>({
|
||||
path: `/auth/login`,
|
||||
method: "POST",
|
||||
body: request,
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @description Logout user and clear cookies
|
||||
*
|
||||
* @tags auth
|
||||
* @name LogoutCreate
|
||||
* @summary Logout
|
||||
* @request POST:/auth/logout
|
||||
*/
|
||||
logoutCreate: (params: RequestParams = {}) =>
|
||||
this.request<ResponseResponse, any>({
|
||||
path: `/auth/logout`,
|
||||
method: "POST",
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @description Register a new user
|
||||
*
|
||||
* @tags auth
|
||||
* @name RegisterCreate
|
||||
* @summary Register
|
||||
* @request POST:/auth/register
|
||||
*/
|
||||
registerCreate: (
|
||||
request: AuthRegisterRequest,
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<ResponseResponse, ResponseResponse>({
|
||||
path: `/auth/register`,
|
||||
method: "POST",
|
||||
body: request,
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @description Reset password using token
|
||||
*
|
||||
* @tags auth
|
||||
* @name ResetPasswordCreate
|
||||
* @summary Reset Password
|
||||
* @request POST:/auth/reset-password
|
||||
*/
|
||||
resetPasswordCreate: (
|
||||
request: AuthResetPasswordRequest,
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<ResponseResponse, ResponseResponse>({
|
||||
path: `/auth/reset-password`,
|
||||
method: "POST",
|
||||
body: request,
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
};
|
||||
payments = {
|
||||
/**
|
||||
* @description Create a new payment
|
||||
*
|
||||
* @tags payment
|
||||
* @name PaymentsCreate
|
||||
* @summary Create Payment
|
||||
* @request POST:/payments
|
||||
* @secure
|
||||
*/
|
||||
paymentsCreate: (
|
||||
request: PaymentCreatePaymentRequest,
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<ResponseResponse, ResponseResponse>({
|
||||
path: `/payments`,
|
||||
method: "POST",
|
||||
body: request,
|
||||
secure: true,
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
};
|
||||
plans = {
|
||||
/**
|
||||
* @description Get all active plans
|
||||
*
|
||||
* @tags plan
|
||||
* @name PlansList
|
||||
* @summary List Plans
|
||||
* @request GET:/plans
|
||||
* @secure
|
||||
*/
|
||||
plansList: (params: RequestParams = {}) =>
|
||||
this.request<
|
||||
ResponseResponse & {
|
||||
data: {
|
||||
plans: ModelPlan[];
|
||||
}
|
||||
},
|
||||
ResponseResponse
|
||||
>({
|
||||
path: `/plans`,
|
||||
method: "GET",
|
||||
secure: true,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
};
|
||||
videos = {
|
||||
/**
|
||||
* @description Get paginated videos
|
||||
*
|
||||
* @tags video
|
||||
* @name VideosList
|
||||
* @summary List Videos
|
||||
* @request GET:/videos
|
||||
* @secure
|
||||
*/
|
||||
videosList: (
|
||||
query?: {
|
||||
/**
|
||||
* Page number
|
||||
* @default 1
|
||||
*/
|
||||
page?: number;
|
||||
/**
|
||||
* Page size
|
||||
* @default 10
|
||||
*/
|
||||
limit?: number;
|
||||
},
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<ResponseResponse & {
|
||||
data: {
|
||||
limit: number;
|
||||
page: number;
|
||||
total: number;
|
||||
videos: ModelVideo[];
|
||||
}
|
||||
}, ResponseResponse>({
|
||||
path: `/videos`,
|
||||
method: "GET",
|
||||
query: query,
|
||||
secure: true,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @description Create video record after upload
|
||||
*
|
||||
* @tags video
|
||||
* @name VideosCreate
|
||||
* @summary Create Video
|
||||
* @request POST:/videos
|
||||
* @secure
|
||||
*/
|
||||
videosCreate: (
|
||||
request: VideoCreateVideoRequest,
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<
|
||||
ResponseResponse & {
|
||||
data?: ModelVideo;
|
||||
},
|
||||
ResponseResponse
|
||||
>({
|
||||
path: `/videos`,
|
||||
method: "POST",
|
||||
body: request,
|
||||
secure: true,
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @description Generate presigned URL for video upload
|
||||
*
|
||||
* @tags video
|
||||
* @name UploadUrlCreate
|
||||
* @summary Get Upload URL
|
||||
* @request POST:/videos/upload-url
|
||||
* @secure
|
||||
*/
|
||||
uploadUrlCreate: (
|
||||
request: VideoUploadURLRequest,
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
this.request<ResponseResponse, ResponseResponse>({
|
||||
path: `/videos/upload-url`,
|
||||
method: "POST",
|
||||
body: request,
|
||||
secure: true,
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* @description Get video details by ID
|
||||
*
|
||||
* @tags video
|
||||
* @name VideosDetail
|
||||
* @summary Get Video
|
||||
* @request GET:/videos/{id}
|
||||
* @secure
|
||||
*/
|
||||
videosDetail: (id: string, params: RequestParams = {}) =>
|
||||
this.request<
|
||||
ResponseResponse & {
|
||||
data?: ModelVideo;
|
||||
},
|
||||
ResponseResponse
|
||||
>({
|
||||
path: `/videos/${id}`,
|
||||
method: "GET",
|
||||
secure: true,
|
||||
format: "json",
|
||||
...params,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const client = new Api({
|
||||
baseUrl: 'r',
|
||||
// baseUrl: 'https://api.pipic.fun',
|
||||
customFetch
|
||||
});
|
||||
@@ -1,65 +1,6 @@
|
||||
import { TinyRpcClientAdapter, TinyRpcError } from "@hiogawa/tiny-rpc";
|
||||
import { Result } from "@hiogawa/utils";
|
||||
|
||||
const GET_PAYLOAD_PARAM = "payload";
|
||||
|
||||
export function httpClientAdapter(opts: {
|
||||
url: string;
|
||||
pathsForGET?: string[];
|
||||
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
||||
}): TinyRpcClientAdapter {
|
||||
return {
|
||||
send: async (data) => {
|
||||
const url = [opts.url, data.path].join("/");
|
||||
const payload = JSON.stringify(data.args);
|
||||
const method = opts.pathsForGET?.includes(data.path)
|
||||
? "GET"
|
||||
: "POST";
|
||||
|
||||
const extraHeaders = opts.headers ? await opts.headers() : {};
|
||||
|
||||
let req: Request;
|
||||
if (method === "GET") {
|
||||
req = new Request(
|
||||
url +
|
||||
"?" +
|
||||
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
|
||||
{
|
||||
headers: extraHeaders
|
||||
}
|
||||
);
|
||||
} else {
|
||||
req = new Request(url, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...extraHeaders
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
let res: Response;
|
||||
res = await fetch(req);
|
||||
if (!res.ok) {
|
||||
// throw new Error(`HTTP error: ${res.status}`);
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
data: { message: await res.text() },
|
||||
internal: true,
|
||||
})
|
||||
);
|
||||
// throw TinyRpcError.deserialize(res.status);
|
||||
}
|
||||
const result: Result<unknown, unknown> = JSON.parse(
|
||||
await res.text()
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw TinyRpcError.deserialize(result.value);
|
||||
}
|
||||
return result.value;
|
||||
},
|
||||
};
|
||||
export const customFetch = (url: string, options: RequestInit) => {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
@@ -1,70 +1,31 @@
|
||||
import { TinyRpcClientAdapter, TinyRpcError } from "@hiogawa/tiny-rpc";
|
||||
import { Result } from "@hiogawa/utils";
|
||||
import { tryGetContext } from "hono/context-storage";
|
||||
|
||||
const GET_PAYLOAD_PARAM = "payload";
|
||||
export const customFetch = (url: string, options: RequestInit) => {
|
||||
options.credentials = "include";
|
||||
const c = tryGetContext<any>();
|
||||
if (!c) {
|
||||
throw new Error("Hono context not found in SSR");
|
||||
}
|
||||
// Merge headers properly - keep original options.headers and add request headers
|
||||
const reqHeaders = new Headers(c.req.header());
|
||||
// Remove headers that shouldn't be forwarded
|
||||
reqHeaders.delete("host");
|
||||
reqHeaders.delete("connection");
|
||||
|
||||
export function httpClientAdapter(opts: {
|
||||
url: string;
|
||||
pathsForGET?: string[];
|
||||
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
||||
}): TinyRpcClientAdapter {
|
||||
return {
|
||||
send: async (data) => {
|
||||
const url = [opts.url, data.path].join("/");
|
||||
const payload = JSON.stringify(data.args);
|
||||
const method = opts.pathsForGET?.includes(data.path)
|
||||
? "GET"
|
||||
: "POST";
|
||||
let req: Request;
|
||||
if (method === "GET") {
|
||||
req = new Request(
|
||||
url +
|
||||
"?" +
|
||||
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
|
||||
);
|
||||
} else {
|
||||
req = new Request(url, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
let res: Response;
|
||||
if (import.meta.env.SSR) {
|
||||
const c = tryGetContext<any>();
|
||||
if (!c) {
|
||||
throw new Error("Hono context not found in SSR");
|
||||
}
|
||||
Object.entries(c.req.header()).forEach(([k, v]) => {
|
||||
req.headers.append(k, v);
|
||||
});
|
||||
res = await c.get("fetch")(req);
|
||||
} else {
|
||||
res = await fetch(req);
|
||||
}
|
||||
if (!res.ok) {
|
||||
// throw new Error(`HTTP error: ${res.status}`);
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
data: { message: await res.text() },
|
||||
internal: true,
|
||||
})
|
||||
);
|
||||
// throw TinyRpcError.deserialize(res.status);
|
||||
}
|
||||
const result: Result<unknown, unknown> = JSON.parse(
|
||||
await res.text()
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw TinyRpcError.deserialize(result.value);
|
||||
}
|
||||
return result.value;
|
||||
},
|
||||
};
|
||||
}
|
||||
const mergedHeaders: Record<string, string> = {};
|
||||
reqHeaders.forEach((value, key) => {
|
||||
mergedHeaders[key] = value;
|
||||
});
|
||||
options.headers = {
|
||||
...mergedHeaders,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, "")].join("");
|
||||
return fetch(apiUrl, options).then(async (res) => {
|
||||
res.headers.getSetCookie()?.forEach((cookie) => {
|
||||
c.header("Set-Cookie", cookie);
|
||||
});
|
||||
return res;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import {
|
||||
proxyTinyRpc,
|
||||
TinyRpcClientAdapter,
|
||||
TinyRpcError,
|
||||
} from "@hiogawa/tiny-rpc";
|
||||
import type { RpcRoutes } from "./rpc";
|
||||
import { Result } from "@hiogawa/utils";
|
||||
import { httpClientAdapter } from "@httpClientAdapter";
|
||||
// console.log("httpClientAdapter module:", httpClientAdapter.toString());
|
||||
declare let __host__: string;
|
||||
const endpoint = "/rpc";
|
||||
const url = import.meta.env.SSR ? "http://localhost" : "";
|
||||
import { auth } from "../lib/firebase";
|
||||
|
||||
export const client = proxyTinyRpc<RpcRoutes>({
|
||||
adapter: httpClientAdapter({
|
||||
url: url + endpoint,
|
||||
pathsForGET: [],
|
||||
headers: async () => {
|
||||
if (import.meta.env.SSR) return {}; // No client auth on server for now
|
||||
const user = auth.currentUser;
|
||||
if (user) {
|
||||
// Force refresh if needed or just get token
|
||||
const token = await user.getIdToken();
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}),
|
||||
});
|
||||
25
src/components/ClientOnly.tsx
Normal file
25
src/components/ClientOnly.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// export default defineComponent((props, context) => {
|
||||
// if (typeof window === 'undefined') {
|
||||
// return () => context.slots.default ? context.slots.default() : null;
|
||||
// }
|
||||
// return () => null;
|
||||
// });
|
||||
|
||||
import { ref, onMounted } from "vue";
|
||||
const ClientOnly = defineComponent({
|
||||
name: "ClientOnly",
|
||||
setup(_p, { slots }) {
|
||||
const isClient = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
isClient.value = true;
|
||||
});
|
||||
return () => {
|
||||
if (isClient.value) {
|
||||
return slots.default ? slots.default() : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
},
|
||||
});
|
||||
export default ClientOnly;
|
||||
@@ -1,44 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import Add from "@/components/icons/Add.vue";
|
||||
import Bell from "@/components/icons/Bell.vue";
|
||||
import Home from "@/components/icons/Home.vue";
|
||||
import Video from "@/components/icons/Video.vue";
|
||||
import Credit from "@/components/icons/Credit.vue";
|
||||
import Upload from "./icons/Upload.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { createStaticVNode } from "vue";
|
||||
import DashboardNav from "./DashboardNav.vue";
|
||||
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center";
|
||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||
const links = [
|
||||
{ href: "/fdsfsd", label: "app", icon: homeHoist, type: "btn" },
|
||||
{ href: "/", label: "Home", icon: Home, type: "a" },
|
||||
{ href: "/upload", label: "Upload", icon: Upload, type: "a" },
|
||||
{ href: "/video", label: "Video", icon: Video, type: "a" },
|
||||
{ href: "/plans", label: "Plans", icon: Credit, type: "a" },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, type: "a" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class=":uno: fixed left-0 w-18 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen border-r border-gray-200 bg-white">
|
||||
<component :is="i.type === 'a' ? 'router-link' : 'div'" v-for="i in links" :key="i.label"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label"
|
||||
:class="cn(className, $route.path === i.href && 'bg-primary/15')">
|
||||
<component :is="i.icon" :filled="$route.path === i.href" />
|
||||
</component>
|
||||
<div class="w-12 h-12 rounded-2xl hover:bg-primary/15 flex">
|
||||
<button class="h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated" @click="auth.logout()">
|
||||
<img class="h-8 w-8 rounded-full m-a ring-1 ring-white"
|
||||
src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex flex-1 overflow-hidden md:ps-18">
|
||||
<div class="flex-1 overflow-auto p-4 bg-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
||||
<DashboardNav />
|
||||
<main class="flex flex-1 flex-col transition-all duration-300 ease-in-out bg-page md:ps-18">
|
||||
<div class=":uno: flex-1 overflow-auto p-4 bg-page rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition enter-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0 transform translate-y-4"
|
||||
@@ -50,5 +19,6 @@ const links = [
|
||||
</Transition>
|
||||
</router-view>
|
||||
</div>
|
||||
<GlobalUploadIndicator />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
56
src/components/DashboardNav.vue
Normal file
56
src/components/DashboardNav.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
import Bell from "@/components/icons/Bell.vue";
|
||||
import Home from "@/components/icons/Home.vue";
|
||||
import Video from "@/components/icons/Video.vue";
|
||||
import Credit from "@/components/icons/Credit.vue";
|
||||
import Upload from "@/components/icons/Upload.vue";
|
||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createStaticVNode, ref } from "vue";
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||
const profileHoist = createStaticVNode(`<div class="h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated">
|
||||
<img class="h-8 w-8 rounded-full m-a ring-1 ring-white"
|
||||
src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" />
|
||||
</div>`, 1);
|
||||
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
||||
const isNotificationOpen = ref(false);
|
||||
|
||||
const handleNotificationClick = (event: Event) => {
|
||||
notificationPopover.value?.toggle(event);
|
||||
};
|
||||
|
||||
const links = [
|
||||
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
||||
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
||||
{ href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||
{ href: "/video", label: "Video", icon: Video, type: "a", className },
|
||||
{ href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
||||
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex shrink-0' },
|
||||
];
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center">
|
||||
|
||||
<template v-for="i in links" :key="i.label">
|
||||
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" @click="i.action && i.action($event)"
|
||||
:class="cn(
|
||||
i.className,
|
||||
($route.path === i.href || i.isActive?.value) && 'bg-primary/15'
|
||||
)">
|
||||
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
||||
:filled="$route.path === i.href || i.isActive?.value" />
|
||||
</component>
|
||||
</template>
|
||||
</header>
|
||||
<ClientOnly>
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
||||
</ClientOnly>
|
||||
</template>
|
||||
103
src/components/GlobalUploadIndicator.vue
Normal file
103
src/components/GlobalUploadIndicator.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
|
||||
|
||||
const { items, totalSize, completeCount, pendingCount } = useUploadQueue();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const isVisible = computed(() => {
|
||||
// Show if there are items AND we are NOT on the upload page
|
||||
return items.value.length > 0 && route.path !== '/upload';
|
||||
});
|
||||
|
||||
const progress = computed(() => {
|
||||
if (items.value.length === 0) return 0;
|
||||
const totalProgress = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
|
||||
return Math.round(totalProgress / items.value.length);
|
||||
});
|
||||
|
||||
const isUploading = computed(() => {
|
||||
return items.value.some(i => i.status === 'uploading' || i.status === 'fetching');
|
||||
});
|
||||
|
||||
const toggleOpen = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const goToUploadPage = () => {
|
||||
router.push('/upload');
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible" class="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
||||
|
||||
<!-- Mini Queue Popover -->
|
||||
<Transition enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-95" enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-95">
|
||||
<div v-if="isOpen"
|
||||
class="bg-white rounded-2xl shadow-xl border border-gray-100 p-4 mb-2 w-80 max-h-[60vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-gray-100">
|
||||
<h3 class="font-bold text-slate-800">Uploads</h3>
|
||||
<button @click="goToUploadPage" class="text-xs font-bold text-accent hover:underline">
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto min-h-0 space-y-3 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:rounded">
|
||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" :minimal="true"
|
||||
class="border-b border-slate-100 last:border-0 !rounded-none" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Floating Button -->
|
||||
<button @click="toggleOpen"
|
||||
class="relative flex items-center gap-3 bg-white pl-4 pr-5 py-3 rounded-full shadow-[0_8px_30px_rgba(0,0,0,0.12)] border border-slate-100 hover:-translate-y-1 transition-all duration-300 group">
|
||||
<!-- Progress Ring -->
|
||||
<div class="relative w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-full h-full -rotate-90 text-slate-100" viewBox="0 0 36 36">
|
||||
<path class="stroke-current" fill="none" stroke-width="3"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<svg class="absolute inset-0 w-full h-full -rotate-90 text-accent transition-all duration-500"
|
||||
viewBox="0 0 36 36" :style="{ strokeDasharray: `${progress}, 100` }">
|
||||
<path class="stroke-current" fill="none" stroke-width="3" stroke-linecap="round"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-center text-accent">
|
||||
<svg v-if="!isUploading && completeCount === items.length" xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<span v-else class="text-[10px] font-bold">{{ progress }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-bold text-slate-800 group-hover:text-accent transition-colors">
|
||||
{{ isUploading ? 'Uploading...' : (completeCount === items.length ? 'Completed' : 'Pending') }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{{ completeCount }} / {{ items.length }} files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingCount"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm border-2 border-white">
|
||||
{{ pendingCount }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
187
src/components/NotificationDrawer.vue
Normal file
187
src/components/NotificationDrawer.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
// Emit event when visibility changes
|
||||
const emit = defineEmits(['change']);
|
||||
|
||||
type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
const visible = ref(false);
|
||||
const drawerRef = ref(null);
|
||||
|
||||
// Mock notifications data
|
||||
const notifications = ref<Notification[]>([
|
||||
{
|
||||
id: '1',
|
||||
type: 'video',
|
||||
title: 'Video processing complete',
|
||||
message: 'Your video "Summer Vacation 2024" has been successfully processed.',
|
||||
time: '2 min ago',
|
||||
read: false,
|
||||
actionUrl: '/video',
|
||||
actionLabel: 'View'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'payment',
|
||||
title: 'Payment successful',
|
||||
message: 'Your subscription to Pro Plan has been renewed successfully.',
|
||||
time: '1 hour ago',
|
||||
read: false,
|
||||
actionUrl: '/payments-and-plans',
|
||||
actionLabel: 'Receipt'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'warning',
|
||||
title: 'Storage almost full',
|
||||
message: 'You have used 85% of your storage quota.',
|
||||
time: '3 hours ago',
|
||||
read: false,
|
||||
actionUrl: '/payments-and-plans',
|
||||
actionLabel: 'Upgrade'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'success',
|
||||
title: 'Upload successful',
|
||||
message: 'Your video "Product Demo v2" has been uploaded.',
|
||||
time: '1 day ago',
|
||||
read: true
|
||||
}
|
||||
]);
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
|
||||
|
||||
const toggle = (event?: Event) => {
|
||||
console.log(event);
|
||||
// Prevent event propagation to avoid immediate closure by onClickOutside
|
||||
if (event) {
|
||||
// We don't stop propagation here to let other listeners work,
|
||||
// but we might need to ignore the trigger element in onClickOutside
|
||||
// However, since the trigger is outside this component, simple toggle logic works
|
||||
// if we use a small delay or ignore ref.
|
||||
// Best approach: "toggle" usually comes from a button click.
|
||||
}
|
||||
visible.value = !visible.value;
|
||||
console.log(visible.value);
|
||||
};
|
||||
|
||||
// Handle click outside
|
||||
onClickOutside(drawerRef, (event) => {
|
||||
// We can just set visible to false.
|
||||
// Note: If the toggle button is clicked, it might toggle it back on immediately
|
||||
// if the click event propagates.
|
||||
// The user calls `toggle` from the parent's button click handler.
|
||||
// If that button is outside `drawerRef` (which it is), this will fire.
|
||||
// To avoid conflict, we usually check if the target is the trigger.
|
||||
// But we don't have access to the trigger ref here.
|
||||
// A common workaround is to use `ignore` option if we had the ref,
|
||||
// or relying on the fact that if this fires, it sets specific state to false.
|
||||
// If the button click then fires `toggle`, it might set it true again.
|
||||
// Optimization: check if visible is true before closing.
|
||||
if (visible.value) {
|
||||
visible.value = false;
|
||||
}
|
||||
}, {
|
||||
ignore: ['[name="Notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger
|
||||
});
|
||||
|
||||
const handleMarkRead = (id: string) => {
|
||||
const notification = notifications.value.find(n => n.id === id);
|
||||
if (notification) notification.read = true;
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
notifications.value = notifications.value.filter(n => n.id !== id);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
notifications.value.forEach(n => n.read = true);
|
||||
};
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('change', val);
|
||||
});
|
||||
|
||||
defineExpose({ toggle });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-x-4" enter-to-class="opacity-100 translate-x-0"
|
||||
leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 translate-x-0"
|
||||
leave-to-class="opacity-0 -translate-x-4">
|
||||
<div v-if="visible" ref="drawerRef"
|
||||
class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-gray-900">Notifications</h3>
|
||||
<span v-if="unreadCount > 0"
|
||||
class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full">
|
||||
{{ unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<button v-if="unreadCount > 0" @click="handleMarkAllRead"
|
||||
class="text-sm text-primary hover:underline font-medium">
|
||||
Mark all read
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Notification List -->
|
||||
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
|
||||
<template v-if="notifications.length > 0">
|
||||
<div v-for="notification in notifications" :key="notification.id"
|
||||
class="border-b border-gray-50 last:border-0">
|
||||
<NotificationItem :notification="notification" @mark-read="handleMarkRead"
|
||||
@delete="handleDelete" isDrawer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="py-12 text-center">
|
||||
<span class="i-lucide-bell-off w-12 h-12 text-gray-300 mx-auto block mb-3"></span>
|
||||
<p class="text-gray-500 text-sm">No notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="notifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
|
||||
<router-link to="/notification"
|
||||
class="block w-full text-center text-sm text-primary font-medium hover:underline"
|
||||
@click="visible = false">
|
||||
View all notifications
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<!-- <style>
|
||||
.notification-popover {
|
||||
border-radius: 16px !important;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-popover .p-popover-content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style> -->
|
||||
@@ -1,3 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import Toast from './ui/form/Toast.vue';
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<router-view/>
|
||||
</template>
|
||||
55
src/components/dashboard/EmptyState.vue
Normal file
55
src/components/dashboard/EmptyState.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||
<!-- Icon or Image -->
|
||||
<div v-if="imageUrl" class="mb-6">
|
||||
<img :src="imageUrl" :alt="title" class="w-64 h-64 object-contain opacity-80" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="icon"
|
||||
class="mb-6 w-24 h-24 rounded-full bg-gray-100 flex items-center justify-center"
|
||||
>
|
||||
<span :class="[icon, 'w-12 h-12 text-gray-400']" />
|
||||
</div>
|
||||
<div v-else class="mb-6 w-24 h-24 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<span class="i-heroicons-inbox w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">{{ title }}</h3>
|
||||
<p v-if="description" class="text-gray-600 mb-6 max-w-md">{{ description }}</p>
|
||||
|
||||
<!-- Action Button -->
|
||||
<button
|
||||
v-if="actionLabel && onAction"
|
||||
@click="onAction"
|
||||
class="btn btn-outline-primary press-animated flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Slot for custom actions -->
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
77
src/components/dashboard/PageHeader.vue
Normal file
77
src/components/dashboard/PageHeader.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Component, VNode } from 'vue';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
interface Action {
|
||||
label: string;
|
||||
icon?: string | VNode;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string | VNode | Component;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
actions?: Action[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const getButtonClass = (variant?: string) => {
|
||||
const baseClass = 'px-4 py-2.5 rounded-lg font-medium transition-all press-animated flex items-center gap-2';
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return `${baseClass} bg-primary hover:bg-primary-600 text-white shadow-sm`;
|
||||
case 'danger':
|
||||
return `${baseClass} bg-danger hover:bg-danger-600 text-white shadow-sm`;
|
||||
case 'secondary':
|
||||
default:
|
||||
return `${baseClass} bg-white hover:bg-gray-50 text-gray-700 border border-gray-300`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('page-header mb-6')">
|
||||
<!-- Breadcrumb -->
|
||||
<nav v-if="breadcrumbs && breadcrumbs.length" class="flex items-center gap-2 text-sm mb-2">
|
||||
<template v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||
<router-link v-if="crumb.to" :to="crumb.to" class="text-gray-500 hover:text-primary transition-colors">
|
||||
{{ crumb.label }}
|
||||
</router-link>
|
||||
<span v-else class="text-gray-700 font-medium">{{ crumb.label }}</span>
|
||||
|
||||
<span v-if="index < breadcrumbs.length - 1" class="w-4 h-4 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Title & Actions -->
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 v-if="typeof props.title == 'string'" class="text-3xl font-bold text-gray-900 mb-1">{{ title }}</h1>
|
||||
<component v-else :is="title" />
|
||||
<p v-if="description" class="text-gray-600">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="actions && actions.length" class="flex items-center gap-2 flex-shrink-0">
|
||||
<button v-for="(action, index) in actions" :key="index" @click="action.onClick"
|
||||
:class="getButtonClass(action.variant)">
|
||||
<component v-if="action.icon" :is="action.icon" class="w-5 h-5" />
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
83
src/components/dashboard/StatsCard.vue
Normal file
83
src/components/dashboard/StatsCard.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { VNode } from 'vue';
|
||||
|
||||
interface Trend {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string | VNode;
|
||||
trend?: Trend;
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
color: 'primary'
|
||||
});
|
||||
|
||||
// const gradients = {
|
||||
// primary: 'from-primary/20 to-primary/5',
|
||||
// success: 'from-success/20 to-success/5',
|
||||
// warning: 'from-yellow-100 to-yellow-50',
|
||||
// danger: 'from-danger/20 to-danger/5',
|
||||
// info: 'from-info/20 to-info/5',
|
||||
// };
|
||||
|
||||
const iconColors = {
|
||||
primary: 'text-primary',
|
||||
success: 'text-success',
|
||||
warning: 'text-yellow-600',
|
||||
danger: 'text-danger',
|
||||
info: 'text-info',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'transform translate-y-0 relative overflow-hidden rounded-2xl p-6 bg-surface',
|
||||
// gradients[color],
|
||||
'border border-gray-300 transition-all duration-300',
|
||||
// 'group cursor-pointer'
|
||||
]">
|
||||
<!-- Content -->
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">{{ title }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ value }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="icon" :class="[
|
||||
'w-12 h-12 rounded-xl flex items-center justify-center',
|
||||
'bg-white/80 shadow-sm',
|
||||
iconColors[color]
|
||||
]">
|
||||
<component :is="icon" class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trend Indicator -->
|
||||
<div v-if="trend" class="flex items-center gap-1 text-sm">
|
||||
<span :class="[
|
||||
'flex items-center gap-1 font-medium',
|
||||
trend.isPositive ? 'text-success' : 'text-danger'
|
||||
]">
|
||||
<!-- <span :class="[
|
||||
'w-4 h-4',
|
||||
trend.isPositive ? 'i-heroicons-arrow-trending-up' : 'i-heroicons-arrow-trending-down'
|
||||
]" /> -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path v-if="trend.isPositive" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 17l6-6 4 4 8-8" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 7l-6 6-4-4-8 8" />
|
||||
</svg>
|
||||
{{ Math.abs(trend.value) }}%
|
||||
</span>
|
||||
<span class="text-gray-500">vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -15,5 +15,5 @@
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
16
src/components/icons/AlertTriangleIcon.vue
Normal file
16
src/components/icons/AlertTriangleIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
17
src/components/icons/ArrowDownTray.vue
Normal file
17
src/components/icons/ArrowDownTray.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
v-if="!filled">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" v-else>
|
||||
<path fill-rule="evenodd"
|
||||
d="M12 2.25a.75.75 0 0 1 .75.75v11.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 1 1 1.06-1.06l3.22 3.22V3a.75.75 0 0 1 .75-.75Zm-9 13.5a.75.75 0 0 1 .75.75v2.25a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5V16.5a.75.75 0 0 1 1.5 0v2.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V16.5a.75.75 0 0 1 .75-.75Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
15
src/components/icons/ArrowRightIcon.vue
Normal file
15
src/components/icons/ArrowRightIcon.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,18 +1,11 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" height="24" width="24" viewBox="0 0 468 532">
|
||||
<path
|
||||
d="M66 378h337l-13-22c-24-40-36-85-36-131v-15c0-66-54-120-120-120s-120 54-120 120v15c0 46-12 91-35 131l-13 22z"
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M234 10c-13 0-24 11-24 24v10C129 55 66 125 66 210v15c0 37-10 74-29 107l-22 37c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166V34c0-13-11-24-24-24zm168 368H66l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H166z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else height="24" viewBox="-10 -258 468 532">
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="#a6acb9"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
|
||||
<path
|
||||
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/Chart.vue
Normal file
7
src/components/icons/Chart.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="#a6acb9"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 532 468"><path d="M272-184c9 0 16 7 16 16v352c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-352c0-9 7-16 16-16h32zm-32-32c-26 0-48 22-48 48v352c0 27 22 48 48 48h32c27 0 48-21 48-48v-352c0-26-21-48-48-48h-32zM80 8c9 0 16 7 16 16v160c0 9-7 16-16 16H48c-9 0-16-7-16-16V24c0-9 7-16 16-16h32zM48-24C22-24 0-2 0 24v160c0 27 22 48 48 48h32c27 0 48-21 48-48V24c0-26-21-48-48-48H48zm384-96h32c9 0 16 7 16 16v288c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-288c0-9 7-16 16-16zm-48 16v288c0 27 22 48 48 48h32c27 0 48-21 48-48v-288c0-26-21-48-48-48h-32c-26 0-48 22-48 48z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
16
src/components/icons/CheckCircleIcon.vue
Normal file
16
src/components/icons/CheckCircleIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
14
src/components/icons/CheckMarkIcon.vue
Normal file
14
src/components/icons/CheckMarkIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" width="24" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#a6acb9"/><path d="M10 106h512v64H10zm0 0z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else width="24" viewBox="-10 -194 532 404"><path d="M448-136c9 0 16 7 16 16v32H48v-32c0-9 7-16 16-16h384zm16 112v160c0 9-7 16-16 16H64c-9 0-16-7-16-16V-24h416zM64-184c-35 0-64 29-64 64v256c0 35 29 64 64 64h384c35 0 64-29 64-64v-256c0-35-29-64-64-64H64zM80 96c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24h-48c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#a6acb9"/><path d="M10 106h512v64H10zm0 0z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -194 532 404"><path d="M448-136c9 0 16 7 16 16v32H48v-32c0-9 7-16 16-16h384zm16 112v160c0 9-7 16-16 16H64c-9 0-16-7-16-16V-24h416zM64-184c-35 0-64 29-64 64v256c0 35 29 64 64 64h384c35 0 64-29 64-64v-256c0-35-29-64-64-64H64zM80 96c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24h-48c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
16
src/components/icons/CreditCardIcon.vue
Normal file
16
src/components/icons/CreditCardIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path
|
||||
d="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="1" y1="10" x2="23" y2="10"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/HardDriveUpload.vue
Normal file
7
src/components/icons/HardDriveUpload.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 503"><path d="M10 397v32c0 35 29 64 64 64h320c35 0 64-29 64-64v-32c0-35-29-64-64-64H266v32c0 18-14 32-32 32s-32-14-32-32v-32H74c-35 0-64 29-64 64zm392 16c0 13-11 24-24 24s-24-11-24-24 11-24 24-24 24 11 24 24z" fill="#a6acb9"/><path d="M234 397c18 0 32-14 32-32V122l41 42c13 12 33 12 46 0 12-13 12-33 0-46l-96-96c-13-12-33-12-46 0l-96 96c-12 13-12 33 0 46 13 12 33 12 46 0l41-42v243c0 18 14 32 32 32z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -260 468 502"><path d="M248 80c0 13-11 24-24 24s-24-11-24-24v-246l-63 63c-9 9-25 9-34 0s-9-25 0-34l104-104c9-9 25-9 34 0l104 104c9 9 9 25 0 34s-25 9-34 0l-63-63V80zm-96-8H64c-9 0-16 7-16 16v80c0 9 7 16 16 16h320c9 0 16-7 16-16V88c0-9-7-16-16-16h-88V24h88c35 0 64 29 64 64v80c0 35-29 64-64 64H64c-35 0-64-29-64-64V88c0-35 29-64 64-64h88v48zm168 56c0-13 11-24 24-24s24 11 24 24-11 24-24 24-24-11-24-24z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,18 +1,17 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="v-mid m-a" height="24" width="24"
|
||||
viewBox="0 0 539 535">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535">
|
||||
<path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z"
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else class="v-mid m-a" height="24" width="24" viewBox="-11 -259 535 533">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
|
||||
<path
|
||||
d="M272-242c-9-8-23-8-32 0L8-34C-2-25-3-10 6 0s24 11 34 2l8-7v205c0 35 29 64 64 64h288c35 0 64-29 64-64V-5l8 7c10 9 25 8 34-2s8-25-2-34L272-242zM416-48v248c0 9-7 16-16 16H112c-9 0-16-7-16-16V-48l160-144L416-48z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
16
src/components/icons/InfoIcon.vue
Normal file
16
src/components/icons/InfoIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -15,5 +15,5 @@
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/LinkIcon.vue
Normal file
7
src/components/icons/LinkIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -194 596 404"><path d="M160-184C72-184 0-112 0-24v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96v-64h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM192-24h-32v64h256v-64H192z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
17
src/components/icons/PanelLeft.vue
Normal file
17
src/components/icons/PanelLeft.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineProps<{
|
||||
class?: string;
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :class="cn('w-6 h-6', $props.class)" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 3V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</template>
|
||||
18
src/components/icons/SettingsIcon.vue
Normal file
18
src/components/icons/SettingsIcon.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path
|
||||
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.48.48 0 0 0-.59.22L5.09 8.87a.484.484 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.48.48 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.21.08.47 0 .59-.22l1.92-3.32a.48.48 0 0 0-.12-.61l-2.03-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path
|
||||
d="M12.22 2h-.44a2 2 0 0 1-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.72v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z">
|
||||
</path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
17
src/components/icons/TrashIcon.vue
Normal file
17
src/components/icons/TrashIcon.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" width="28" viewBox="0 0 596 468">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="min-w-[28px]" viewBox="0 0 596 468">
|
||||
<path
|
||||
d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z"
|
||||
fill="#a6acb9" />
|
||||
@@ -7,12 +7,12 @@
|
||||
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else width="28" viewBox="-10 -226 596 468">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else class="min-w-[28px]" viewBox="-10 -226 596 468">
|
||||
<path
|
||||
d="M240-216c-88 0-160 72-160 160 0 5 0 10 1 15C33-18 0 31 0 88c0 80 65 144 144 144h304c71 0 128-57 128-128 0-50-28-93-70-114 4-12 6-25 6-38 0-66-54-120-120-120-11 0-23 2-33 5-30-33-72-53-119-53zM128-56c0-62 50-112 112-112 38 0 71 19 91 47 7 10 20 13 30 8 9-4 20-7 31-7 40 0 72 32 72 72 0 14-4 27-11 38-4 7-5 15-2 22s9 13 16 14c35 9 61 41 61 78 0 44-36 80-80 80H144c-53 0-96-43-96-96 0-43 28-79 67-91 11-4 18-16 16-29-2-7-3-16-3-24zm177 7c-9-9-25-9-34 0l-64 64c-9 9-9 25 0 34 10 9 25 9 34 0l23-23v86c0 13 11 24 24 24s24-11 24-24V26l23 23c9 9 25 9 34 0s9-25 0-34l-64-64z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" width="24" viewBox="0 0 532 404">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404">
|
||||
<path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z"
|
||||
fill="#a6acb9" />
|
||||
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else width="24" viewBox="22 -194 564 404">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
|
||||
<path
|
||||
d="M96-136c-9 0-16 7-16 16v256c0 9 7 16 16 16h256c9 0 16-7 16-16v-256c0-9-7-16-16-16H96zm-64 16c0-35 29-64 64-64h256c35 0 64 29 64 64v256c0 35-29 64-64 64H96c-35 0-64-29-64-64v-256zm506-11c4-3 9-5 14-5 13 0 24 11 24 24v240c0 13-11 24-24 24-5 0-10-2-14-5l-74-55V32l64 48V-64l-64 48v-60l74-55z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
16
src/components/icons/VideoIcon.vue
Normal file
16
src/components/icons/VideoIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path
|
||||
d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
17
src/components/icons/XCircleIcon.vue
Normal file
17
src/components/icons/XCircleIcon.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path
|
||||
d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
37
src/components/ui/form/Avatar.vue
Normal file
37
src/components/ui/form/Avatar.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
interface AvatarProps {
|
||||
label?: string;
|
||||
shape?: 'circle' | 'square';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<AvatarProps>(), {
|
||||
shape: 'circle',
|
||||
size: 'medium',
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'w-8 h-8 text-xs',
|
||||
medium: 'w-10 h-10 text-sm',
|
||||
large: 'w-12 h-12 text-base',
|
||||
};
|
||||
|
||||
const shapeClasses = {
|
||||
circle: 'rounded-full',
|
||||
square: 'rounded-lg',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex items-center justify-center font-medium bg-gray-200 text-gray-600',
|
||||
sizeClasses[size],
|
||||
shapeClasses[shape],
|
||||
]"
|
||||
>
|
||||
<slot>
|
||||
{{ label?.charAt(0).toUpperCase() || '?' }}
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
62
src/components/ui/form/Button.vue
Normal file
62
src/components/ui/form/Button.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
interface ButtonProps {
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
variant?: 'primary' | 'secondary' | 'outlined' | 'text';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||
type: 'button',
|
||||
variant: 'primary',
|
||||
size: 'medium',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary text-white hover:opacity-90',
|
||||
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
|
||||
outlined: 'border border-gray-300 bg-transparent hover:bg-gray-50',
|
||||
text: 'bg-transparent hover:bg-gray-100',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
small: 'px-3 py-1.5 text-xs',
|
||||
medium: 'px-4 py-2 text-sm',
|
||||
large: 'px-6 py-3 text-base',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:class="[
|
||||
'inline-flex items-center justify-center font-medium rounded-lg transition-colors',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
loading ? 'cursor-wait' : '',
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
v-if="loading"
|
||||
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<slot>{{ label }}</slot>
|
||||
</button>
|
||||
</template>
|
||||
16
src/components/ui/form/Card.vue
Normal file
16
src/components/ui/form/Card.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
interface CardProps {
|
||||
cardClass?: string;
|
||||
}
|
||||
|
||||
defineProps<CardProps>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="['bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden', cardClass]">
|
||||
<slot name="header" />
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
66
src/components/ui/form/Checkbox.vue
Normal file
66
src/components/ui/form/Checkbox.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
interface CheckboxProps {
|
||||
name: string;
|
||||
value?: any;
|
||||
binary?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CheckboxProps>(), {
|
||||
binary: false,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const formContext = inject<{
|
||||
values: Record<string, any>;
|
||||
errors: Record<string, string>;
|
||||
touched: Record<string, boolean>;
|
||||
handleBlur: (name: string) => void;
|
||||
handleChange: (name: string, value: any) => void;
|
||||
} | null>('form-context', null);
|
||||
|
||||
const error = computed(() => formContext?.errors[props.name]);
|
||||
const isInvalid = computed(() => formContext?.touched[props.name] && error.value);
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => {
|
||||
const val = formContext?.values[props.name];
|
||||
if (props.binary) return !!val;
|
||||
return val?.includes(props.value);
|
||||
},
|
||||
set: (val) => {
|
||||
if (props.binary) {
|
||||
formContext?.handleChange(props.name, val);
|
||||
} else {
|
||||
const current = formContext?.values[props.name] || [];
|
||||
if (val) {
|
||||
formContext?.handleChange(props.name, [...current, props.value]);
|
||||
} else {
|
||||
formContext?.handleChange(props.name, current.filter((v: any) => v !== props.value));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
:id="name + '-' + (value ?? 'binary')"
|
||||
type="checkbox"
|
||||
v-model="modelValue"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary/20',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
isInvalid ? 'border-red-500' : '',
|
||||
]"
|
||||
@blur="formContext?.handleBlur(name)"
|
||||
/>
|
||||
<label v-if="$slots.default" :for="name + '-' + (value ?? 'binary')" class="text-sm text-gray-700">
|
||||
<slot />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
115
src/components/ui/form/Dialog.vue
Normal file
115
src/components/ui/form/Dialog.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
interface DialogProps {
|
||||
visible: boolean;
|
||||
header?: string;
|
||||
style?: Record<string, string>;
|
||||
closable?: boolean;
|
||||
modal?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<DialogProps>(), {
|
||||
closable: true,
|
||||
modal: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
}>();
|
||||
|
||||
const handleClose = () => {
|
||||
if (props.closable) {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (props.modal) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.visible) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
});
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="dialog">
|
||||
<div v-if="visible" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50"
|
||||
@click="handleBackdropClick"
|
||||
/>
|
||||
|
||||
<!-- Dialog -->
|
||||
<div
|
||||
class="relative bg-white rounded-xl shadow-xl w-full max-h-[90vh] overflow-auto"
|
||||
:style="style || { width: '28rem' }"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div v-if="header" class="flex items-center justify-between px-6 py-4 border-b">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ header }}</h3>
|
||||
<button
|
||||
v-if="closable"
|
||||
@click="handleClose"
|
||||
class="p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 pt-4">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-enter-active,
|
||||
.dialog-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-enter-from,
|
||||
.dialog-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.dialog-enter-active .relative,
|
||||
.dialog-leave-active .relative {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-enter-from .relative,
|
||||
.dialog-leave-to .relative {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
58
src/components/ui/form/Field.vue
Normal file
58
src/components/ui/form/Field.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
interface FieldProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<FieldProps>();
|
||||
|
||||
const formContext = inject<{
|
||||
values: Record<string, any>;
|
||||
errors: Record<string, string>;
|
||||
touched: Record<string, boolean>;
|
||||
validators: Record<string, ((value: any) => string | undefined)[]>;
|
||||
handleBlur: (name: string) => void;
|
||||
handleChange: (name: string, value: any) => void;
|
||||
} | null>('form-context', null);
|
||||
|
||||
const error = computed(() => props.name ? formContext?.errors[props.name] : undefined);
|
||||
const isInvalid = computed(() => props.name ? formContext?.touched[props.name] && !!error.value : false);
|
||||
const fieldValue = computed(() => props.name ? formContext?.values[props.name] ?? '' : '');
|
||||
|
||||
const onChange = (value: any) => {
|
||||
if (props.name && formContext) {
|
||||
formContext.handleChange(props.name, value);
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
if (props.name && formContext) {
|
||||
formContext.handleBlur(props.name);
|
||||
}
|
||||
};
|
||||
|
||||
// Provide values to slot
|
||||
const slotProps = {
|
||||
value: fieldValue,
|
||||
error: error,
|
||||
errorMessage: error,
|
||||
isInvalid,
|
||||
name: props.name,
|
||||
onChange,
|
||||
onBlur,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="field flex flex-col gap-1">
|
||||
<label v-if="label" :for="name" class="text-sm font-medium text-gray-700">
|
||||
{{ label }}
|
||||
</label>
|
||||
<slot v-bind="slotProps" />
|
||||
<div v-if="isInvalid && error" class="text-xs text-red-600 mt-1">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
91
src/components/ui/form/Form.vue
Normal file
91
src/components/ui/form/Form.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { provide, reactive } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
initialValues?: Record<string, any>;
|
||||
validators?: Record<string, ((value: any) => string | undefined)[]>;
|
||||
formClass?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: Record<string, any>];
|
||||
}>();
|
||||
|
||||
const errors = reactive<Record<string, string>>({});
|
||||
const touched = reactive<Record<string, boolean>>({});
|
||||
const values = reactive<Record<string, any>>({...props.initialValues});
|
||||
|
||||
// Initialize values
|
||||
if (props.initialValues) {
|
||||
Object.assign(values, props.initialValues);
|
||||
}
|
||||
|
||||
const validateField = (name: string) => {
|
||||
const value = values[name];
|
||||
const fieldValidators = props.validators?.[name] || [];
|
||||
|
||||
for (const validator of fieldValidators) {
|
||||
const error = validator(value);
|
||||
if (error) {
|
||||
errors[name] = error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
delete errors[name];
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateAll = () => {
|
||||
const fieldNames = Object.keys(props.validators || {});
|
||||
let isValid = true;
|
||||
|
||||
for (const name of fieldNames) {
|
||||
if (!validateField(name)) {
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Mark all fields as touched
|
||||
const fieldNames = Object.keys(props.validators || {});
|
||||
for (const name of fieldNames) {
|
||||
touched[name] = true;
|
||||
}
|
||||
|
||||
if (validateAll()) {
|
||||
emit('submit', {...values});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = (name: string) => {
|
||||
touched[name] = true;
|
||||
validateField(name);
|
||||
};
|
||||
|
||||
const handleChange = (name: string, value: any) => {
|
||||
values[name] = value;
|
||||
if (touched[name]) {
|
||||
validateField(name);
|
||||
}
|
||||
};
|
||||
|
||||
// Provide form context to child components
|
||||
provide('form-context', {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
validators: props.validators || {},
|
||||
handleBlur,
|
||||
handleChange,
|
||||
validateField,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" :class="[formClass, 'flex flex-col gap-4 w-full']">
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
55
src/components/ui/form/Input.vue
Normal file
55
src/components/ui/form/Input.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface InputProps {
|
||||
name?: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
fluid?: boolean;
|
||||
modelValue?: string | any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<InputProps>(), {
|
||||
type: 'text',
|
||||
disabled: false,
|
||||
fluid: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
// Handle the v-model binding - support both string and computed ref
|
||||
const inputValue = computed(() => {
|
||||
const val = props.modelValue;
|
||||
// Check if it's a ref/computed
|
||||
if (val && typeof val === 'object' && 'value' in val) {
|
||||
return val.value;
|
||||
}
|
||||
return val ?? '';
|
||||
});
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
emit('update:modelValue', target.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="name"
|
||||
:value="inputValue"
|
||||
@input="onInput"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'px-3 py-2 text-sm border rounded-lg outline-none transition-colors',
|
||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
|
||||
'disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed',
|
||||
fluid ? 'w-full' : '',
|
||||
'border-gray-300',
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
30
src/components/ui/form/ProgressBar.vue
Normal file
30
src/components/ui/form/ProgressBar.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
interface ProgressBarProps {
|
||||
value?: number;
|
||||
showValue?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ProgressBarProps>(), {
|
||||
value: 0,
|
||||
showValue: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div
|
||||
v-if="showValue"
|
||||
class="flex justify-between mb-1"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
<slot name="value">{{ Math.round(value) }}%</slot>
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class="bg-primary h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: `${Math.min(100, Math.max(0, value))}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
24
src/components/ui/form/Skeleton.vue
Normal file
24
src/components/ui/form/Skeleton.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
interface SkeletonProps {
|
||||
width?: string;
|
||||
height?: string;
|
||||
borderRadius?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SkeletonProps>(), {
|
||||
width: '100%',
|
||||
height: '1rem',
|
||||
borderRadius: '0.375rem',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="animate-pulse bg-gray-200"
|
||||
:style="{
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
35
src/components/ui/form/Table.vue
Normal file
35
src/components/ui/form/Table.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
interface TableProps<T> {
|
||||
value: T[];
|
||||
dataKey: string;
|
||||
selection?: T[];
|
||||
tableStyle?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<TableProps<T>>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:selection': [value: T[]];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<table :class="['w-full', tableStyle]">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 bg-gray-50">
|
||||
<slot name="header" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(item, index) in value"
|
||||
:key="String((item as any)[dataKey])"
|
||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<slot name="body" :data="item" :index="index" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
31
src/components/ui/form/Tag.vue
Normal file
31
src/components/ui/form/Tag.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
interface TagProps {
|
||||
value?: string;
|
||||
severity?: 'success' | 'error' | 'warn' | 'info' | 'secondary';
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TagProps>(), {
|
||||
rounded: true,
|
||||
});
|
||||
|
||||
const severityClasses = {
|
||||
success: 'bg-green-100 text-green-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
warn: 'bg-yellow-100 text-yellow-800',
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
secondary: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center px-2.5 py-0.5 text-xs font-medium',
|
||||
rounded ? 'rounded-full' : 'rounded',
|
||||
severity ? severityClasses[severity] : 'bg-gray-100 text-gray-800',
|
||||
]"
|
||||
>
|
||||
<slot>{{ value }}</slot>
|
||||
</span>
|
||||
</template>
|
||||
34
src/components/ui/form/TanStackForm.vue
Normal file
34
src/components/ui/form/TanStackForm.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { useForm } from '@tanstack/vue-form';
|
||||
import { provide } from 'vue';
|
||||
|
||||
interface FormProps {
|
||||
initialValues?: T;
|
||||
onSubmit?: (values: T) => void | Promise<void>;
|
||||
validators?: Record<string, (value: any) => string | undefined>;
|
||||
}
|
||||
|
||||
const props = defineProps<FormProps>();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: props.initialValues,
|
||||
onSubmit: async (values) => {
|
||||
await props.onSubmit?.(values.value);
|
||||
},
|
||||
});
|
||||
|
||||
// Provide form context to child components
|
||||
provide('tanstack-form', form);
|
||||
provide('tanstack-form-validators', props.validators || {});
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault();
|
||||
form.handleSubmit();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit="handleSubmit" class="flex flex-col gap-4 w-full">
|
||||
<slot :form="form" />
|
||||
</form>
|
||||
</template>
|
||||
50
src/components/ui/form/Textarea.vue
Normal file
50
src/components/ui/form/Textarea.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject } from 'vue';
|
||||
|
||||
interface TextareaProps {
|
||||
name: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
fluid?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextareaProps>(), {
|
||||
rows: 3,
|
||||
disabled: false,
|
||||
fluid: true,
|
||||
});
|
||||
|
||||
const formContext = inject<{
|
||||
values: Record<string, any>;
|
||||
errors: Record<string, string>;
|
||||
touched: Record<string, boolean>;
|
||||
handleBlur: (name: string) => void;
|
||||
handleChange: (name: string, value: any) => void;
|
||||
} | null>('form-context', null);
|
||||
|
||||
const error = computed(() => formContext?.errors[props.name]);
|
||||
const isInvalid = computed(() => formContext?.touched[props.name] && error.value);
|
||||
const modelValue = computed({
|
||||
get: () => formContext?.values[props.name] ?? '',
|
||||
set: (val) => formContext?.handleChange(props.name, val),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:id="name"
|
||||
v-model="modelValue"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="[
|
||||
'px-3 py-2 text-sm border rounded-lg outline-none transition-colors resize-none',
|
||||
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
|
||||
'disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed',
|
||||
fluid ? 'w-full' : '',
|
||||
isInvalid ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300',
|
||||
]"
|
||||
@blur="formContext?.handleBlur(name)"
|
||||
/>
|
||||
</template>
|
||||
104
src/components/ui/form/Toast.vue
Normal file
104
src/components/ui/form/Toast.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { provide, ref } from 'vue';
|
||||
|
||||
interface ToastItem {
|
||||
id: string;
|
||||
severity: 'success' | 'error' | 'warn' | 'info';
|
||||
summary: string;
|
||||
detail?: string;
|
||||
life?: number;
|
||||
}
|
||||
|
||||
const toasts = ref<ToastItem[]>([]);
|
||||
|
||||
const addToast = (toast: Omit<ToastItem, 'id'>) => {
|
||||
const id = crypto.randomUUID();
|
||||
const newToast = { ...toast, id };
|
||||
toasts.value.push(newToast);
|
||||
|
||||
const life = toast.life || 5000;
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, life);
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
const index = toasts.value.findIndex(t => t.id === id);
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const toast = {
|
||||
add: addToast,
|
||||
remove: removeToast,
|
||||
};
|
||||
|
||||
provide('toast', toast);
|
||||
|
||||
const severityClasses = {
|
||||
success: 'bg-green-50 text-green-800 border-green-200',
|
||||
error: 'bg-red-50 text-red-800 border-red-200',
|
||||
warn: 'bg-yellow-50 text-yellow-800 border-yellow-200',
|
||||
info: 'bg-blue-50 text-blue-800 border-blue-200',
|
||||
};
|
||||
|
||||
const severityIcons = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
warn: '⚠',
|
||||
info: 'ℹ',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:class="[
|
||||
'flex items-start gap-3 p-4 rounded-lg border shadow-lg',
|
||||
severityClasses[toast.severity],
|
||||
]"
|
||||
>
|
||||
<span class="text-lg">{{ severityIcons[toast.severity] }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium">{{ toast.summary }}</p>
|
||||
<p v-if="toast.detail" class="text-sm opacity-90 mt-1">{{ toast.detail }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="removeToast(toast.id)"
|
||||
class="p-1 hover:bg-black/5 rounded transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
15
src/components/ui/form/index.ts
Normal file
15
src/components/ui/form/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export { default as Avatar } from './Avatar.vue';
|
||||
export { default as Button } from './Button.vue';
|
||||
export { default as Card } from './Card.vue';
|
||||
export { default as Checkbox } from './Checkbox.vue';
|
||||
export { default as Dialog } from './Dialog.vue';
|
||||
export { default as Field } from './Field.vue';
|
||||
export { default as Form } from './Form.vue';
|
||||
export { default as Input } from './Input.vue';
|
||||
export { default as ProgressBar } from './ProgressBar.vue';
|
||||
export { default as Skeleton } from './Skeleton.vue';
|
||||
export { default as Table } from './Table.vue';
|
||||
export { default as Tag } from './Tag.vue';
|
||||
export { default as Textarea } from './Textarea.vue';
|
||||
export { default as Toast } from './Toast.vue';
|
||||
|
||||
164
src/composables/useUploadQueue.ts
Normal file
164
src/composables/useUploadQueue.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'local' | 'remote';
|
||||
status: 'uploading' | 'processing' | 'fetching' | 'complete' | 'error' | 'pending';
|
||||
progress?: number;
|
||||
uploaded?: string;
|
||||
total?: string;
|
||||
speed?: string;
|
||||
thumbnail?: string;
|
||||
file?: File; // Keep reference to file for local uploads
|
||||
url?: string; // Keep reference to url for remote uploads
|
||||
}
|
||||
|
||||
const items = ref<QueueItem[]>([]);
|
||||
|
||||
export function useUploadQueue() {
|
||||
|
||||
const addFiles = (files: FileList) => {
|
||||
const newItems: QueueItem[] = Array.from(files).map((file) => ({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
name: file.name,
|
||||
type: 'local',
|
||||
status: 'pending', // Start as pending
|
||||
progress: 0,
|
||||
uploaded: '0 MB',
|
||||
total: formatSize(file.size),
|
||||
speed: '0 MB/s',
|
||||
file: file,
|
||||
thumbnail: undefined // We could generate a thumbnail here if needed
|
||||
}));
|
||||
|
||||
items.value.push(...newItems);
|
||||
};
|
||||
|
||||
const addRemoteUrls = (urls: string[]) => {
|
||||
const newItems: QueueItem[] = urls.map((url) => ({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
name: url.split('/').pop() || 'Remote File',
|
||||
type: 'remote',
|
||||
status: 'fetching', // Remote URLs start fetching immediately or pending? User said "khi nao nhan upload". Let's use pending.
|
||||
progress: 0,
|
||||
uploaded: '0 MB',
|
||||
total: 'Unknown',
|
||||
speed: '0 MB/s',
|
||||
url: url
|
||||
}));
|
||||
|
||||
// Override status to pending for consistency with user request
|
||||
newItems.forEach(i => i.status = 'pending');
|
||||
|
||||
items.value.push(...newItems);
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
const index = items.value.findIndex(item => item.id === id);
|
||||
if (index !== -1) {
|
||||
items.value.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
const startQueue = () => {
|
||||
items.value.forEach(item => {
|
||||
if (item.status === 'pending') {
|
||||
if (item.type === 'local') {
|
||||
startMockUpload(item.id);
|
||||
} else {
|
||||
startMockRemoteFetch(item.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Mock Upload Logic
|
||||
const startMockUpload = (id: string) => {
|
||||
const item = items.value.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
|
||||
item.status = 'uploading';
|
||||
let progress = 0;
|
||||
const totalSize = item.file ? item.file.size : 1024 * 1024 * 50; // Default 50MB if unknown
|
||||
|
||||
// Random speed between 1MB/s and 5MB/s
|
||||
const speedBytesPerStep = (1024 * 1024) + Math.random() * (1024 * 1024 * 4);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
item.status = 'complete';
|
||||
item.progress = 100;
|
||||
item.uploaded = item.total;
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment progress randomly
|
||||
const increment = Math.random() * 5 + 1; // 1-6% increment
|
||||
progress = Math.min(progress + increment, 100);
|
||||
|
||||
item.progress = Math.floor(progress);
|
||||
|
||||
// Calculate uploaded size string
|
||||
const currentBytes = (progress / 100) * totalSize;
|
||||
item.uploaded = formatSize(currentBytes);
|
||||
|
||||
// Re-randomize speed for realism
|
||||
const currentSpeed = (1024 * 1024) + Math.random() * (1024 * 1024 * 2);
|
||||
item.speed = formatSize(currentSpeed) + '/s';
|
||||
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Mock Remote Fetch Logic
|
||||
const startMockRemoteFetch = (id: string) => {
|
||||
const item = items.value.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
|
||||
item.status = 'fetching'; // Update status to fetching
|
||||
|
||||
// Remote fetch takes some time then completes
|
||||
setTimeout(() => {
|
||||
// Switch to uploading/processing phase if we wanted, or just finish
|
||||
item.status = 'complete';
|
||||
item.progress = 100;
|
||||
}, 3000 + Math.random() * 3000);
|
||||
};
|
||||
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
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 totalSize = computed(() => {
|
||||
let total = 0;
|
||||
items.value.forEach(item => {
|
||||
if (item.file) total += item.file.size;
|
||||
});
|
||||
return formatSize(total);
|
||||
});
|
||||
|
||||
const completeCount = computed(() => {
|
||||
return items.value.filter(i => i.status === 'complete').length;
|
||||
});
|
||||
|
||||
const pendingCount = computed(() => {
|
||||
return items.value.filter(i => i.status === 'pending').length;
|
||||
});
|
||||
|
||||
return {
|
||||
items,
|
||||
addFiles,
|
||||
addRemoteUrls,
|
||||
removeItem,
|
||||
startQueue,
|
||||
totalSize,
|
||||
completeCount,
|
||||
pendingCount
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,15 @@
|
||||
import { Hono } from 'hono'
|
||||
import { createApp } from './main';
|
||||
import { renderToWebStream } from 'vue/server-renderer';
|
||||
import { streamText } from 'hono/streaming';
|
||||
import { renderSSRHead } from '@unhead/vue/server';
|
||||
import { buildBootstrapScript, getHrefFromManifest, loadCssByModules } from './lib/manifest';
|
||||
import { Hono } from 'hono';
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
import { cors } from "hono/cors";
|
||||
import { firebaseAuthMiddleware, rpcServer } from './api/rpc';
|
||||
import { streamText } from 'hono/streaming';
|
||||
import isMobile from 'is-mobile';
|
||||
import { renderToWebStream } from 'vue/server-renderer';
|
||||
import { buildBootstrapScript } from './lib/manifest';
|
||||
import { createTextTransformStreamClass } from './lib/replateStreamText';
|
||||
import { createApp } from './main';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { cssContent } from './lib/primeCssContent';
|
||||
import { styleTags } from './lib/primePassthrough';
|
||||
// @ts-ignore
|
||||
import Base from '@primevue/core/base';
|
||||
const app = new Hono()
|
||||
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
|
||||
// app.use(renderer)
|
||||
app.use('*', contextStorage());
|
||||
app.use(cors(), async (c, next) => {
|
||||
@@ -25,7 +20,32 @@ app.use(cors(), async (c, next) => {
|
||||
};
|
||||
c.set("isMobile", isMobile({ ua }));
|
||||
await next();
|
||||
}, firebaseAuthMiddleware, rpcServer);
|
||||
}, async (c, next) => {
|
||||
const path = c.req.path
|
||||
|
||||
if (path !== '/r' && !path.startsWith('/r/')) {
|
||||
return await next()
|
||||
}
|
||||
const url = new URL(c.req.url)
|
||||
url.host = 'api.pipic.fun'
|
||||
url.protocol = 'https:'
|
||||
url.pathname = path.replace(/^\/r/, '') || '/'
|
||||
url.port = ''
|
||||
// console.log("url", url.toString())
|
||||
// console.log("c.req.raw", c.req.raw)
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
|
||||
return fetch(url.toString(), {
|
||||
method: c.req.method,
|
||||
headers: headers,
|
||||
body: c.req.raw.body,
|
||||
// @ts-ignore
|
||||
duplex: 'half',
|
||||
credentials: 'include'
|
||||
});
|
||||
});
|
||||
app.get("/.well-known/*", (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
@@ -36,12 +56,8 @@ app.get("*", async (c) => {
|
||||
app.provide("honoContext", c);
|
||||
const auth = useAuthStore();
|
||||
auth.$reset();
|
||||
auth.initialized = false;
|
||||
await auth.init();
|
||||
await router.push(url.pathname);
|
||||
await router.isReady();
|
||||
let usedStyles = new Set<String>();
|
||||
Base.setLoadedStyleName = async (name: string) => usedStyles.add(name)
|
||||
return streamText(c, async (stream) => {
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Content-Encoding", "Identity");
|
||||
@@ -50,16 +66,18 @@ app.get("*", async (c) => {
|
||||
// console.log("ctx: ", );
|
||||
await stream.write("<!DOCTYPE html><html lang='en'><head>");
|
||||
await stream.write("<base href='" + url.origin + "'/>");
|
||||
|
||||
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
|
||||
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
|
||||
// await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
|
||||
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
||||
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
|
||||
await stream.write('<link rel="icon" href="/favicon.ico" />');
|
||||
await stream.write(buildBootstrapScript());
|
||||
if (usedStyles.size > 0) {
|
||||
defaultNames.forEach(name => usedStyles.add(name));
|
||||
}
|
||||
await Promise.all(styleTags.filter(tag => usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))).map(tag => stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`)));
|
||||
await stream.write(`</head><body class='${bodyClass}'>`);
|
||||
await stream.pipe(appStream);
|
||||
await stream.pipe(createTextTransformStreamClass(appStream, (text) => text.replace('<div id="anchor-header" class="p-4"></div>', `<div id="anchor-header" class="p-4">${ctx.teleports["#anchor-header"] || ""}</div>`).replace('<div id="anchor-top"></div>', `<div id="anchor-top">${ctx.teleports["#anchor-top"] || ""}</div>`)));
|
||||
delete ctx.teleports
|
||||
delete ctx.__teleportBuffers
|
||||
delete ctx.modules;
|
||||
Object.assign(ctx, { $p: pinia.state.value });
|
||||
await stream.write(`<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape((JSON.stringify(ctx)))}</script>`);
|
||||
await stream.write("</body></html>");
|
||||
|
||||
16
src/lib/directives/clickOutside.ts
Normal file
16
src/lib/directives/clickOutside.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Directive } from 'vue';
|
||||
|
||||
export const vClickOutside: Directive = {
|
||||
mounted(el, binding) {
|
||||
el.__clickOutsideHandler__ = (event: Event) => {
|
||||
if (!(el === event.target || el.contains(event.target as Node))) {
|
||||
binding.value(event);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', el.__clickOutsideHandler__);
|
||||
},
|
||||
unmounted(el) {
|
||||
document.removeEventListener('click', el.__clickOutsideHandler__);
|
||||
delete el.__clickOutsideHandler__;
|
||||
},
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { initializeApp } from "firebase/app";
|
||||
import { createUserWithEmailAndPassword, getAuth, GoogleAuthProvider, sendPasswordResetEmail, signInWithEmailAndPassword, signInWithPopup } from "firebase/auth";
|
||||
// TODO: Add SDKs for Firebase products that you want to use
|
||||
// https://firebase.google.com/docs/web/setup#available-libraries
|
||||
|
||||
// Your web app's Firebase configuration
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyBTr0L5qxdrVEtWuP2oAicJXQvVyeXkMts",
|
||||
authDomain: "trello-7ea39.firebaseapp.com",
|
||||
projectId: "trello-7ea39",
|
||||
storageBucket: "trello-7ea39.firebasestorage.app",
|
||||
messagingSenderId: "321067890572",
|
||||
appId: "1:321067890572:web:e34e1e657125d37be688a9"
|
||||
};
|
||||
|
||||
// Initialize Firebase
|
||||
const appFirebase = initializeApp(firebaseConfig);
|
||||
const provider = new GoogleAuthProvider();
|
||||
export const auth = getAuth(appFirebase);
|
||||
export const googleAuth = () => signInWithPopup(auth, provider).then((result) => {
|
||||
console.log('User signed in:', result.user);
|
||||
return result;
|
||||
})
|
||||
export const emailAuth = (username: string, password: string) => {
|
||||
return signInWithEmailAndPassword(auth, username, password)
|
||||
}
|
||||
export const forgotPassword = (email: string) => {
|
||||
return sendPasswordResetEmail(auth, email)
|
||||
.then(() => {
|
||||
console.log('Password reset email sent');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error sending password reset email:', error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
export const signUp = (email: string, password: string) => {
|
||||
return createUserWithEmailAndPassword(auth, email, password)
|
||||
.then((userCredential) => {
|
||||
console.log('User signed up:', userCredential.user);
|
||||
return userCredential.user;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error signing up:', error);
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// import { initializeApp, getApps, cert } from 'firebase-admin/app';
|
||||
// import { getAuth } from 'firebase-admin/auth';
|
||||
// import certJson from './cert.json';
|
||||
// const firebaseAdminConfig = {
|
||||
// credential: cert(certJson as any)
|
||||
// };
|
||||
|
||||
// if (getApps().length === 0) {
|
||||
// initializeApp(firebaseAdminConfig);
|
||||
// }
|
||||
|
||||
// export const adminAuth = getAuth();
|
||||
File diff suppressed because one or more lines are too long
123
src/lib/replateStreamText.ts
Normal file
123
src/lib/replateStreamText.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
interface TextTransformStreamOptions {
|
||||
encoding?: string;
|
||||
handleSplitChunks?: boolean;
|
||||
bufferSize?: number;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class Transformer để xử lý text transform
|
||||
*/
|
||||
class TextTransformer implements Transformer<Uint8Array, Uint8Array> {
|
||||
private buffer: string = '';
|
||||
private decoder: TextDecoder;
|
||||
private encoder: TextEncoder;
|
||||
|
||||
constructor(
|
||||
private transformFn: (text: string) => string,
|
||||
private options: Required<Omit<TextTransformStreamOptions, 'onError'>> & {
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
) {
|
||||
this.decoder = new TextDecoder(this.options.encoding);
|
||||
this.encoder = new TextEncoder();
|
||||
}
|
||||
|
||||
transform(
|
||||
chunk: Uint8Array,
|
||||
controller: TransformStreamDefaultController<Uint8Array>
|
||||
): void {
|
||||
try {
|
||||
const chunkText = this.decoder.decode(chunk, { stream: true });
|
||||
const fullText = this.buffer + chunkText;
|
||||
this.buffer = '';
|
||||
|
||||
let processedText: string;
|
||||
try {
|
||||
processedText = this.transformFn(fullText);
|
||||
} catch (transformError) {
|
||||
this.handleError(transformError);
|
||||
processedText = fullText;
|
||||
}
|
||||
|
||||
if (this.options.handleSplitChunks) {
|
||||
this.handleSplitChunks(processedText, controller);
|
||||
} else {
|
||||
controller.enqueue(this.encoder.encode(processedText));
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError(error);
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
flush(controller: TransformStreamDefaultController<Uint8Array>): void {
|
||||
try {
|
||||
if (this.buffer) {
|
||||
const finalText = this.decoder.decode();
|
||||
const remainingText = this.buffer + finalText;
|
||||
|
||||
if (remainingText) {
|
||||
let processedText: string;
|
||||
try {
|
||||
processedText = this.transformFn(remainingText);
|
||||
} catch (transformError) {
|
||||
this.handleError(transformError);
|
||||
processedText = remainingText;
|
||||
}
|
||||
controller.enqueue(this.encoder.encode(processedText));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError(error);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSplitChunks(
|
||||
text: string,
|
||||
controller: TransformStreamDefaultController<Uint8Array>
|
||||
): void {
|
||||
const lastNewline = text.lastIndexOf('\n');
|
||||
const lastSpace = text.lastIndexOf(' ');
|
||||
const lastBreak = Math.max(lastNewline, lastSpace);
|
||||
|
||||
if (lastBreak > text.length - this.options.bufferSize && lastBreak > 0) {
|
||||
const safePart = text.slice(0, lastBreak + 1);
|
||||
this.buffer = text.slice(lastBreak + 1);
|
||||
controller.enqueue(this.encoder.encode(safePart));
|
||||
} else {
|
||||
controller.enqueue(this.encoder.encode(text));
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: unknown): void {
|
||||
if (this.options.onError) {
|
||||
this.options.onError(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cách 2: Sử dụng class Transformer
|
||||
*/
|
||||
export function createTextTransformStreamClass(
|
||||
inputStream: ReadableStream<Uint8Array>,
|
||||
transformFn: (text: string) => string,
|
||||
options: TextTransformStreamOptions = {}
|
||||
): ReadableStream<Uint8Array> {
|
||||
const {
|
||||
encoding = 'utf-8',
|
||||
handleSplitChunks = true,
|
||||
bufferSize = 1024,
|
||||
onError
|
||||
} = options;
|
||||
|
||||
const transformer = new TextTransformer(transformFn, {
|
||||
encoding,
|
||||
handleSplitChunks,
|
||||
bufferSize,
|
||||
onError
|
||||
});
|
||||
|
||||
return inputStream.pipeThrough(new TransformStream(transformer));
|
||||
}
|
||||
77
src/lib/swr/cache/adapters/localStorage.ts
vendored
77
src/lib/swr/cache/adapters/localStorage.ts
vendored
@@ -1,77 +0,0 @@
|
||||
import SWRVCache, { type ICacheItem } from '..'
|
||||
import type { IKey } from '../../types'
|
||||
|
||||
/**
|
||||
* LocalStorage cache adapter for swrv data cache.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
|
||||
*/
|
||||
export default class LocalStorageCache extends SWRVCache<any> {
|
||||
private STORAGE_KEY
|
||||
|
||||
constructor (key = 'swrv', ttl = 0) {
|
||||
super(ttl)
|
||||
this.STORAGE_KEY = key
|
||||
}
|
||||
|
||||
private encode (storage: any) { return JSON.stringify(storage) }
|
||||
private decode (storage: any) { return JSON.parse(storage) }
|
||||
|
||||
get (k: IKey): ICacheItem<IKey> {
|
||||
const item = localStorage.getItem(this.STORAGE_KEY)
|
||||
if (item) {
|
||||
const _key = this.serializeKey(k)
|
||||
const itemParsed: ICacheItem<any> = JSON.parse(item)[_key]
|
||||
|
||||
if (itemParsed?.expiresAt === null) {
|
||||
itemParsed.expiresAt = Infinity // localStorage sets Infinity to 'null'
|
||||
}
|
||||
|
||||
return itemParsed
|
||||
}
|
||||
|
||||
return undefined as any
|
||||
}
|
||||
|
||||
set (k: string, v: any, ttl: number) {
|
||||
let payload = {}
|
||||
const _key = this.serializeKey(k)
|
||||
const timeToLive = ttl || this.ttl
|
||||
const storage = localStorage.getItem(this.STORAGE_KEY)
|
||||
const now = Date.now()
|
||||
const item = {
|
||||
data: v,
|
||||
createdAt: now,
|
||||
expiresAt: timeToLive ? now + timeToLive : Infinity
|
||||
}
|
||||
|
||||
if (storage) {
|
||||
payload = this.decode(storage)
|
||||
(payload as any)[_key] = item
|
||||
} else {
|
||||
payload = { [_key]: item }
|
||||
}
|
||||
|
||||
this.dispatchExpire(timeToLive, item, _key)
|
||||
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
|
||||
}
|
||||
|
||||
dispatchExpire (ttl: number, item: any, serializedKey: string) {
|
||||
ttl && setTimeout(() => {
|
||||
const current = Date.now()
|
||||
const hasExpired = current >= item.expiresAt
|
||||
if (hasExpired) this.delete(serializedKey)
|
||||
}, ttl)
|
||||
}
|
||||
|
||||
delete (serializedKey: string) {
|
||||
const storage = localStorage.getItem(this.STORAGE_KEY)
|
||||
let payload = {} as Record<string, any>
|
||||
|
||||
if (storage) {
|
||||
payload = this.decode(storage)
|
||||
delete payload[serializedKey]
|
||||
}
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
|
||||
}
|
||||
}
|
||||
72
src/lib/swr/cache/index.ts
vendored
72
src/lib/swr/cache/index.ts
vendored
@@ -1,72 +0,0 @@
|
||||
import type { IKey } from '../types'
|
||||
import hash from '../lib/hash'
|
||||
export interface ICacheItem<Data> {
|
||||
data: Data,
|
||||
createdAt: number,
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
function serializeKeyDefault (key: IKey): string {
|
||||
if (typeof key === 'function') {
|
||||
try {
|
||||
key = key()
|
||||
} catch (err) {
|
||||
// dependencies not ready
|
||||
key = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(key)) {
|
||||
key = hash(key)
|
||||
} else {
|
||||
// convert null to ''
|
||||
key = String(key || '')
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
export default class SWRVCache<CacheData> {
|
||||
protected ttl: number
|
||||
private items?: Map<string, ICacheItem<CacheData>>
|
||||
|
||||
constructor (ttl = 0) {
|
||||
this.items = new Map()
|
||||
this.ttl = ttl
|
||||
}
|
||||
|
||||
serializeKey (key: IKey): string {
|
||||
return serializeKeyDefault(key)
|
||||
}
|
||||
|
||||
get (k: string): ICacheItem<CacheData> {
|
||||
const _key = this.serializeKey(k)
|
||||
return this.items!.get(_key)!
|
||||
}
|
||||
|
||||
set (k: string, v: any, ttl: number) {
|
||||
const _key = this.serializeKey(k)
|
||||
const timeToLive = ttl || this.ttl
|
||||
const now = Date.now()
|
||||
const item = {
|
||||
data: v,
|
||||
createdAt: now,
|
||||
expiresAt: timeToLive ? now + timeToLive : Infinity
|
||||
}
|
||||
|
||||
this.dispatchExpire(timeToLive, item, _key)
|
||||
this.items!.set(_key, item)
|
||||
}
|
||||
|
||||
dispatchExpire (ttl: number, item: any, serializedKey: string) {
|
||||
ttl && setTimeout(() => {
|
||||
const current = Date.now()
|
||||
const hasExpired = current >= item.expiresAt
|
||||
if (hasExpired) this.delete(serializedKey)
|
||||
}, ttl)
|
||||
}
|
||||
|
||||
delete (serializedKey: string) {
|
||||
this.items!.delete(serializedKey)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import SWRVCache from './cache'
|
||||
import useSWRV, { mutate } from './use-swrv'
|
||||
|
||||
export {
|
||||
type IConfig
|
||||
} from './types'
|
||||
export { mutate, SWRVCache }
|
||||
export default useSWRV
|
||||
@@ -1,44 +0,0 @@
|
||||
// From https://github.com/vercel/swr/blob/master/src/libs/hash.ts
|
||||
// use WeakMap to store the object->key mapping
|
||||
// so the objects can be garbage collected.
|
||||
// WeakMap uses a hashtable under the hood, so the lookup
|
||||
// complexity is almost O(1).
|
||||
const table = new WeakMap()
|
||||
|
||||
// counter of the key
|
||||
let counter = 0
|
||||
|
||||
// hashes an array of objects and returns a string
|
||||
export default function hash (args: any[]): string {
|
||||
if (!args.length) return ''
|
||||
let key = 'arg'
|
||||
for (let i = 0; i < args.length; ++i) {
|
||||
let _hash
|
||||
if (
|
||||
args[i] === null ||
|
||||
(typeof args[i] !== 'object' && typeof args[i] !== 'function')
|
||||
) {
|
||||
// need to consider the case that args[i] is a string:
|
||||
// args[i] _hash
|
||||
// "undefined" -> '"undefined"'
|
||||
// undefined -> 'undefined'
|
||||
// 123 -> '123'
|
||||
// null -> 'null'
|
||||
// "null" -> '"null"'
|
||||
if (typeof args[i] === 'string') {
|
||||
_hash = '"' + args[i] + '"'
|
||||
} else {
|
||||
_hash = String(args[i])
|
||||
}
|
||||
} else {
|
||||
if (!table.has(args[i])) {
|
||||
_hash = counter
|
||||
table.set(args[i], counter++)
|
||||
} else {
|
||||
_hash = table.get(args[i])
|
||||
}
|
||||
}
|
||||
key += '@' + _hash
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
function isOnline (): boolean {
|
||||
if (typeof navigator.onLine !== 'undefined') {
|
||||
return navigator.onLine
|
||||
}
|
||||
// always assume it's online
|
||||
return true
|
||||
}
|
||||
|
||||
function isDocumentVisible (): boolean {
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
typeof document.visibilityState !== 'undefined'
|
||||
) {
|
||||
return document.visibilityState !== 'hidden'
|
||||
}
|
||||
// always assume it's visible
|
||||
return true
|
||||
}
|
||||
|
||||
const fetcher = (url: string | Request) => fetch(url).then(res => res.json())
|
||||
|
||||
export default {
|
||||
isOnline,
|
||||
isDocumentVisible,
|
||||
fetcher
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Ref, WatchSource } from 'vue'
|
||||
import SWRVCache from './cache'
|
||||
import LocalStorageCache from './cache/adapters/localStorage'
|
||||
|
||||
export type fetcherFn<Data> = (...args: any) => Data | Promise<Data>
|
||||
|
||||
export interface IConfig<
|
||||
Data = any,
|
||||
Fn extends fetcherFn<Data> = fetcherFn<Data>
|
||||
> {
|
||||
refreshInterval?: number
|
||||
cache?: LocalStorageCache | SWRVCache<any>
|
||||
dedupingInterval?: number
|
||||
ttl?: number
|
||||
serverTTL?: number
|
||||
revalidateOnFocus?: boolean
|
||||
revalidateDebounce?: number
|
||||
shouldRetryOnError?: boolean
|
||||
errorRetryInterval?: number
|
||||
errorRetryCount?: number
|
||||
fetcher?: Fn,
|
||||
isOnline?: () => boolean
|
||||
isDocumentVisible?: () => boolean
|
||||
}
|
||||
|
||||
export interface revalidateOptions {
|
||||
shouldRetryOnError?: boolean,
|
||||
errorRetryCount?: number,
|
||||
forceRevalidate?: boolean,
|
||||
}
|
||||
|
||||
export interface IResponse<Data = any, Error = any> {
|
||||
data: Ref<Data | undefined>
|
||||
error: Ref<Error | undefined>
|
||||
isValidating: Ref<boolean>
|
||||
isLoading: Ref<boolean>
|
||||
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => Promise<void>
|
||||
}
|
||||
|
||||
export type keyType = string | any[] | null | undefined
|
||||
|
||||
export type IKey = keyType | WatchSource<keyType>
|
||||
@@ -1,470 +0,0 @@
|
||||
/** ____
|
||||
*--------------/ \.------------------/
|
||||
* / swrv \. / //
|
||||
* / / /\. / //
|
||||
* / _____/ / \. /
|
||||
* / / ____/ . \. /
|
||||
* / \ \_____ \. /
|
||||
* / . \_____ \ \ / //
|
||||
* \ _____/ / ./ / //
|
||||
* \ / _____/ ./ /
|
||||
* \ / / . ./ /
|
||||
* \ / / ./ /
|
||||
* . \/ ./ / //
|
||||
* \ ./ / //
|
||||
* \.. / /
|
||||
* . ||| /
|
||||
* ||| /
|
||||
* . ||| / //
|
||||
* ||| / //
|
||||
* ||| /
|
||||
*/
|
||||
import {
|
||||
reactive,
|
||||
watch,
|
||||
ref,
|
||||
toRefs,
|
||||
// isRef,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
getCurrentInstance,
|
||||
isReadonly,
|
||||
onServerPrefetch,
|
||||
isRef,
|
||||
useSSRContext,
|
||||
type FunctionPlugin,
|
||||
inject
|
||||
} from 'vue'
|
||||
import webPreset from './lib/web-preset'
|
||||
import SWRVCache from './cache'
|
||||
import type { IConfig, IKey, IResponse, fetcherFn, revalidateOptions } from './types'
|
||||
import { tinyassert } from "@hiogawa/utils";
|
||||
|
||||
type StateRef<Data, Error> = {
|
||||
data: Data, error: Error, isValidating: boolean, isLoading: boolean, revalidate: Function, key: any
|
||||
};
|
||||
|
||||
const DATA_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
|
||||
const REF_CACHE = new SWRVCache<StateRef<any, any>[]>()
|
||||
const PROMISES_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
|
||||
|
||||
const defaultConfig: IConfig = {
|
||||
cache: DATA_CACHE,
|
||||
refreshInterval: 0,
|
||||
ttl: 0,
|
||||
serverTTL: 1000,
|
||||
dedupingInterval: 2000,
|
||||
revalidateOnFocus: true,
|
||||
revalidateDebounce: 0,
|
||||
shouldRetryOnError: true,
|
||||
errorRetryInterval: 5000,
|
||||
errorRetryCount: 5,
|
||||
fetcher: webPreset.fetcher,
|
||||
isOnline: webPreset.isOnline,
|
||||
isDocumentVisible: webPreset.isDocumentVisible
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the refs for later revalidation
|
||||
*/
|
||||
function setRefCache(key: string, theRef: StateRef<any, any>, ttl: number) {
|
||||
const refCacheItem = REF_CACHE.get(key)
|
||||
if (refCacheItem) {
|
||||
refCacheItem.data.push(theRef)
|
||||
} else {
|
||||
// #51 ensures ref cache does not evict too soon
|
||||
const gracePeriod = 5000
|
||||
REF_CACHE.set(key, [theRef], ttl > 0 ? ttl + gracePeriod : ttl)
|
||||
}
|
||||
}
|
||||
|
||||
function onErrorRetry(revalidate: (any: any, opts: revalidateOptions) => void, errorRetryCount: number, config: IConfig): void {
|
||||
if (!(config as any).isDocumentVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.errorRetryCount !== undefined && errorRetryCount > config.errorRetryCount) {
|
||||
return
|
||||
}
|
||||
|
||||
const count = Math.min(errorRetryCount || 0, (config as any).errorRetryCount)
|
||||
const timeout = count * (config as any).errorRetryInterval
|
||||
setTimeout(() => {
|
||||
revalidate(null, { errorRetryCount: count + 1, shouldRetryOnError: true })
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Main mutation function for receiving data from promises to change state and
|
||||
* set data cache
|
||||
*/
|
||||
const mutate = async <Data>(key: string, res: Promise<Data> | Data, cache = DATA_CACHE, ttl = defaultConfig.ttl) => {
|
||||
let data, error, isValidating
|
||||
|
||||
if (isPromise(res)) {
|
||||
try {
|
||||
data = await res
|
||||
} catch (err) {
|
||||
error = err
|
||||
}
|
||||
} else {
|
||||
data = res
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
isValidating = false
|
||||
|
||||
const newData = { data, error, isValidating }
|
||||
if (typeof data !== 'undefined') {
|
||||
try {
|
||||
cache.set(key, newData, Number(ttl))
|
||||
} catch (err) {
|
||||
console.error('swrv(mutate): failed to set cache', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate all swrv instances with new data
|
||||
*/
|
||||
const stateRef = REF_CACHE.get(key)
|
||||
if (stateRef && stateRef.data.length) {
|
||||
// This filter fixes #24 race conditions to only update ref data of current
|
||||
// key, while data cache will continue to be updated if revalidation is
|
||||
// fired
|
||||
let refs = stateRef.data.filter(r => r.key === key)
|
||||
|
||||
refs.forEach((r, idx) => {
|
||||
if (typeof newData.data !== 'undefined') {
|
||||
r.data = newData.data
|
||||
}
|
||||
r.error = newData.error
|
||||
r.isValidating = newData.isValidating
|
||||
r.isLoading = newData.isValidating
|
||||
|
||||
const isLast = idx === refs.length - 1
|
||||
if (!isLast) {
|
||||
// Clean up refs that belonged to old keys
|
||||
delete refs[idx]
|
||||
}
|
||||
})
|
||||
|
||||
refs = refs.filter(Boolean)
|
||||
}
|
||||
|
||||
return newData
|
||||
}
|
||||
|
||||
/* Stale-While-Revalidate hook to handle fetching, caching, validation, and more... */
|
||||
function useSWRV<Data = any, Error = any>(
|
||||
key: IKey
|
||||
): IResponse<Data, Error>
|
||||
function useSWRV<Data = any, Error = any>(
|
||||
key: IKey,
|
||||
fn: fetcherFn<Data> | undefined | null,
|
||||
config?: IConfig
|
||||
): IResponse<Data, Error>
|
||||
function useSWRV<Data = any, Error = any>(...args: any[]): IResponse<Data, Error> {
|
||||
const injectedConfig = inject<Partial<IConfig> | null>('swrv-config', null)
|
||||
tinyassert(injectedConfig, 'Injected swrv-config must be an object')
|
||||
let key: IKey
|
||||
let fn: fetcherFn<Data> | undefined | null
|
||||
let config: IConfig = { ...defaultConfig, ...injectedConfig }
|
||||
let unmounted = false
|
||||
let isHydrated = false
|
||||
|
||||
const instance = getCurrentInstance() as any
|
||||
const vm = instance?.proxy || instance // https://github.com/vuejs/composition-api/pull/520
|
||||
if (!vm) {
|
||||
console.error('Could not get current instance, check to make sure that `useSwrv` is declared in the top level of the setup function.')
|
||||
throw new Error('Could not get current instance')
|
||||
}
|
||||
|
||||
const IS_SERVER = typeof window === 'undefined' || false
|
||||
// #region ssr
|
||||
const isSsrHydration = Boolean(
|
||||
!IS_SERVER &&
|
||||
window !== undefined && (window as any).__SSR_STATE__.swrv)
|
||||
// #endregion
|
||||
if (args.length >= 1) {
|
||||
key = args[0]
|
||||
}
|
||||
if (args.length >= 2) {
|
||||
fn = args[1]
|
||||
}
|
||||
if (args.length > 2) {
|
||||
config = {
|
||||
...config,
|
||||
...args[2]
|
||||
}
|
||||
}
|
||||
|
||||
const ttl = IS_SERVER ? config.serverTTL : config.ttl
|
||||
const keyRef = typeof key === 'function' ? (key as any) : ref(key)
|
||||
|
||||
if (typeof fn === 'undefined') {
|
||||
// use the global fetcher
|
||||
fn = config.fetcher
|
||||
}
|
||||
|
||||
let stateRef: StateRef<Data, Error> | null = null
|
||||
// #region ssr
|
||||
if (isSsrHydration) {
|
||||
// component was ssrHydrated, so make the ssr reactive as the initial data
|
||||
|
||||
const swrvState = (window as any).__SSR_STATE__.swrv || []
|
||||
const swrvKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
|
||||
if (swrvKey !== undefined && swrvKey !== null) {
|
||||
const nodeState = swrvState[swrvKey] || []
|
||||
const instanceState = nodeState[nanoHex(isRef(keyRef) ? keyRef.value : keyRef())]
|
||||
|
||||
if (instanceState) {
|
||||
stateRef = reactive(instanceState)
|
||||
isHydrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
if (!stateRef) {
|
||||
stateRef = reactive({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isValidating: true,
|
||||
isLoading: true,
|
||||
key: null
|
||||
}) as StateRef<Data, Error>
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate the cache, mutate data
|
||||
*/
|
||||
const revalidate = async (data?: fetcherFn<Data>, opts?: revalidateOptions) => {
|
||||
const isFirstFetch = stateRef.data === undefined
|
||||
const keyVal = keyRef.value
|
||||
if (!keyVal) { return }
|
||||
|
||||
const cacheItem = config.cache!.get(keyVal)
|
||||
const newData = cacheItem && cacheItem.data
|
||||
|
||||
stateRef.isValidating = true
|
||||
stateRef.isLoading = !newData
|
||||
if (newData) {
|
||||
stateRef.data = newData.data
|
||||
stateRef.error = newData.error
|
||||
}
|
||||
|
||||
const fetcher = data || fn
|
||||
if (
|
||||
!fetcher ||
|
||||
(!(config as any).isDocumentVisible() && !isFirstFetch) ||
|
||||
(opts?.forceRevalidate !== undefined && !opts?.forceRevalidate)
|
||||
) {
|
||||
stateRef.isValidating = false
|
||||
stateRef.isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Dedupe items that were created in the last interval #76
|
||||
if (cacheItem) {
|
||||
const shouldRevalidate = Boolean(
|
||||
((Date.now() - cacheItem.createdAt) >= (config as any).dedupingInterval) || opts?.forceRevalidate
|
||||
)
|
||||
|
||||
if (!shouldRevalidate) {
|
||||
stateRef.isValidating = false
|
||||
stateRef.isLoading = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const trigger = async () => {
|
||||
const promiseFromCache = PROMISES_CACHE.get(keyVal)
|
||||
if (!promiseFromCache) {
|
||||
const fetcherArgs = Array.isArray(keyVal) ? keyVal : [keyVal]
|
||||
const newPromise = fetcher(...fetcherArgs)
|
||||
PROMISES_CACHE.set(keyVal, newPromise, (config as any).dedupingInterval)
|
||||
await mutate(keyVal, newPromise, (config as any).cache, ttl)
|
||||
} else {
|
||||
await mutate(keyVal, promiseFromCache.data, (config as any).cache, ttl)
|
||||
}
|
||||
stateRef.isValidating = false
|
||||
stateRef.isLoading = false
|
||||
PROMISES_CACHE.delete(keyVal)
|
||||
if (stateRef.error !== undefined) {
|
||||
const shouldRetryOnError = !unmounted && config.shouldRetryOnError && (opts ? opts.shouldRetryOnError : true)
|
||||
if (shouldRetryOnError) {
|
||||
onErrorRetry(revalidate, opts ? Number(opts.errorRetryCount) : 1, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newData && config.revalidateDebounce) {
|
||||
setTimeout(async () => {
|
||||
if (!unmounted) {
|
||||
await trigger()
|
||||
}
|
||||
}, config.revalidateDebounce)
|
||||
} else {
|
||||
await trigger()
|
||||
}
|
||||
}
|
||||
|
||||
const revalidateCall = async () => revalidate(null as any, { shouldRetryOnError: false })
|
||||
let timer: any = null
|
||||
/**
|
||||
* Setup polling
|
||||
*/
|
||||
onMounted(() => {
|
||||
const tick = async () => {
|
||||
// component might un-mount during revalidate, so do not set a new timeout
|
||||
// if this is the case, but continue to revalidate since promises can't
|
||||
// be cancelled and new hook instances might rely on promise/data cache or
|
||||
// from pre-fetch
|
||||
if (!stateRef.error && (config as any).isOnline()) {
|
||||
// if API request errored, we stop polling in this round
|
||||
// and let the error retry function handle it
|
||||
await revalidate()
|
||||
} else {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.refreshInterval && !unmounted) {
|
||||
timer = setTimeout(tick, config.refreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.refreshInterval) {
|
||||
timer = setTimeout(tick, config.refreshInterval)
|
||||
}
|
||||
if (config.revalidateOnFocus) {
|
||||
document.addEventListener('visibilitychange', revalidateCall, false)
|
||||
window.addEventListener('focus', revalidateCall, false)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Teardown
|
||||
*/
|
||||
onUnmounted(() => {
|
||||
unmounted = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (config.revalidateOnFocus) {
|
||||
document.removeEventListener('visibilitychange', revalidateCall, false)
|
||||
window.removeEventListener('focus', revalidateCall, false)
|
||||
}
|
||||
const refCacheItem = REF_CACHE.get(keyRef.value)
|
||||
if (refCacheItem) {
|
||||
refCacheItem.data = refCacheItem.data.filter((ref) => ref !== stateRef)
|
||||
}
|
||||
})
|
||||
|
||||
// #region ssr
|
||||
if (IS_SERVER) {
|
||||
const ssrContext = useSSRContext()
|
||||
// make sure srwv exists in ssrContext
|
||||
let swrvRes: Record<string, any> = {}
|
||||
if (ssrContext) {
|
||||
swrvRes = ssrContext.swrv = ssrContext.swrv || swrvRes
|
||||
}
|
||||
|
||||
const ssrKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
|
||||
// if (!vm.$vnode || (vm.$node && !vm.$node.data)) {
|
||||
// vm.$vnode = {
|
||||
// data: { attrs: { 'data-swrv-key': ssrKey } }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const attrs = (vm.$vnode.data.attrs = vm.$vnode.data.attrs || {})
|
||||
// attrs['data-swrv-key'] = ssrKey
|
||||
// // Nuxt compatibility
|
||||
// if (vm.$ssrContext && vm.$ssrContext.nuxt) {
|
||||
// vm.$ssrContext.nuxt.swrv = swrvRes
|
||||
// }
|
||||
if (ssrContext) {
|
||||
ssrContext.swrv = swrvRes
|
||||
}
|
||||
onServerPrefetch(async () => {
|
||||
await revalidate()
|
||||
if (!swrvRes[ssrKey]) swrvRes[ssrKey] = {}
|
||||
|
||||
swrvRes[ssrKey][nanoHex(keyRef.value)] = {
|
||||
data: stateRef.data,
|
||||
error: stateRef.error,
|
||||
isValidating: stateRef.isValidating
|
||||
}
|
||||
})
|
||||
}
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Revalidate when key dependencies change
|
||||
*/
|
||||
try {
|
||||
watch(keyRef, (val) => {
|
||||
if (!isReadonly(keyRef)) {
|
||||
keyRef.value = val
|
||||
}
|
||||
stateRef.key = val
|
||||
stateRef.isValidating = Boolean(val)
|
||||
setRefCache(keyRef.value, stateRef, Number(ttl))
|
||||
|
||||
if (!IS_SERVER && !isHydrated && keyRef.value) {
|
||||
revalidate()
|
||||
}
|
||||
isHydrated = false
|
||||
}, {
|
||||
immediate: true
|
||||
})
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
const res: IResponse = {
|
||||
...toRefs(stateRef),
|
||||
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => revalidate(data, {
|
||||
...opts,
|
||||
forceRevalidate: true
|
||||
})
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
function isPromise<T>(p: any): p is Promise<T> {
|
||||
return p !== null && typeof p === 'object' && typeof p.then === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* string to hex 8 chars
|
||||
* @param name string
|
||||
* @returns string
|
||||
*/
|
||||
function nanoHex(name: string): string {
|
||||
try {
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
const chr = name.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + chr
|
||||
hash |= 0 // Convert to 32bit integer
|
||||
}
|
||||
let hex = (hash >>> 0).toString(16)
|
||||
while (hex.length < 8) {
|
||||
hex = '0' + hex
|
||||
}
|
||||
return hex
|
||||
} catch {
|
||||
console.error("err name: ", name)
|
||||
return '0000'
|
||||
}
|
||||
}
|
||||
export const vueSWR = (swrvConfig: Partial<IConfig> = defaultConfig): FunctionPlugin => (app) => {
|
||||
app.config.globalProperties.$swrv = useSWRV
|
||||
// app.provide('swrv', useSWRV)
|
||||
app.provide('swrv-config', swrvConfig)
|
||||
}
|
||||
export { mutate }
|
||||
export default useSWRV
|
||||
@@ -7,7 +7,7 @@ export function cn(...inputs: ClassValue[]) {
|
||||
}
|
||||
export function debounce<Func extends (...args: any[]) => any>(func: Func, wait: number): Func {
|
||||
let timeout: ReturnType<typeof setTimeout> | null;
|
||||
return function(this: any, ...args: any[]) {
|
||||
return function (this: any, ...args: any[]) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = setTimeout(() => {
|
||||
func.apply(this, args);
|
||||
@@ -47,4 +47,46 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const formatBytes = (bytes?: number) => {
|
||||
if (!bytes) 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];
|
||||
};
|
||||
|
||||
export const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '0:00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
export const getStatusClass = (status?: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
31
src/main.ts
31
src/main.ts
@@ -1,56 +1,33 @@
|
||||
import { createHead as CSRHead } from "@unhead/vue/client";
|
||||
import { createHead as SSRHead } from "@unhead/vue/server";
|
||||
import { createPinia } from "pinia";
|
||||
import { createSSRApp } from 'vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
|
||||
import { vueSWR } from './lib/swr/use-swrv';
|
||||
import createAppRouter from './routes';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import { createPinia } from "pinia";
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
import Tooltip from 'primevue/tooltip';
|
||||
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen"
|
||||
export function createApp() {
|
||||
const pinia = createPinia();
|
||||
const app = createSSRApp(withErrorBoundary(RouterView));
|
||||
const head = import.meta.env.SSR ? SSRHead() : CSRHead();
|
||||
|
||||
|
||||
app.use(head);
|
||||
app.use(PrimeVue, {
|
||||
// unstyled: true,
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: '.my-app-dark',
|
||||
cssLayer: false,
|
||||
// cssLayer: {
|
||||
// name: 'primevue',
|
||||
// order: 'theme, base, primevue'
|
||||
// }
|
||||
}
|
||||
}
|
||||
});
|
||||
app.use(ToastService);
|
||||
app.directive('nh', {
|
||||
created(el) {
|
||||
el.__v_skip = true;
|
||||
}
|
||||
});
|
||||
app.directive("tooltip", Tooltip)
|
||||
if (!import.meta.env.SSR) {
|
||||
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
|
||||
(window as any)[key] = value;
|
||||
});
|
||||
if ((window as any).$p ) {
|
||||
if ((window as any).$p) {
|
||||
pinia.state.value = (window as any).$p;
|
||||
}
|
||||
}
|
||||
app.use(pinia);
|
||||
app.use(vueSWR({revalidateOnFocus: false}));
|
||||
const router = createAppRouter();
|
||||
app.use(router);
|
||||
|
||||
|
||||
return { app, router, head, pinia, bodyClass };
|
||||
}
|
||||
321
src/mocks/videos.ts
Normal file
321
src/mocks/videos.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import type { ModelVideo } from "@/api/client";
|
||||
|
||||
export const mockVideos: ModelVideo[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Getting Started with Stream UI',
|
||||
description: 'A comprehensive guide to using the new Stream UI platform for your daily tasks.',
|
||||
thumbnail: 'https://picsum.photos/seed/video1/640/360',
|
||||
duration: 345, // 5m 45s
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 45, // 45MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), // 2 days ago
|
||||
views: 12500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Advanced Editing Techniques',
|
||||
description: 'Learn how to edit your videos like a pro using our built-in tools.',
|
||||
thumbnail: 'https://picsum.photos/seed/video2/640/360',
|
||||
duration: 890, // 14m 50s
|
||||
status: 'processing',
|
||||
processing_status: '75%',
|
||||
size: 1024 * 1024 * 128, // 128MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago
|
||||
views: 0,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Project Alpha Demo',
|
||||
description: 'Internal demonstration of the upcoming Project Alpha features.',
|
||||
thumbnail: 'https://picsum.photos/seed/video3/640/360',
|
||||
duration: 120, // 2m 00s
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 25, // 25MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(), // 1 week ago
|
||||
views: 340,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Weekly Team Standup',
|
||||
description: 'Recording of the weekly engineering team standup meeting.',
|
||||
thumbnail: 'https://picsum.photos/seed/video4/640/360',
|
||||
duration: 1800, // 30m 00s
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 350, // 350MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), // 2 weeks ago
|
||||
views: 12,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Funny Cat Compilation',
|
||||
description: 'A collection of the funniest cat videos found on the internet.',
|
||||
thumbnail: 'https://picsum.photos/seed/video5/640/360',
|
||||
duration: 600, // 10m 00s
|
||||
status: 'failed',
|
||||
size: 1024 * 1024 * 80, // 80MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago
|
||||
views: 0,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Product Launch Event 2024',
|
||||
description: 'Full coverage of our annual product launch event in San Francisco.',
|
||||
thumbnail: 'https://picsum.photos/seed/video6/640/360',
|
||||
duration: 5400, // 1h 30m
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 1024 * 2.5, // 2.5GB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), // 1 month ago
|
||||
views: 45000,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Tutorial: React vs Vue',
|
||||
description: 'Comparing the two most popular frontend frameworks.',
|
||||
thumbnail: 'https://picsum.photos/seed/video7/640/360',
|
||||
duration: 1540,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 200,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||
views: 8900,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '16',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '17',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '18',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '19',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '20',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '21',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '22',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
id: '23',
|
||||
title: 'Nature Documentary - 4K',
|
||||
description: 'Breathtaking views of mountains and rivers.',
|
||||
thumbnail: 'https://picsum.photos/seed/video8/640/360',
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
]
|
||||
|
||||
interface FetchVideosParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
searchQuery?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export const fetchMockVideos = async ({ page, limit, searchQuery, status }: FetchVideosParams) => {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
let filtered = [...mockVideos];
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(v =>
|
||||
v.title?.toLowerCase().includes(query) ||
|
||||
v.description?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (status && status !== 'all') {
|
||||
filtered = filtered.filter(v => v.status?.toLowerCase() === status.toLowerCase());
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
|
||||
// Pagination
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const data = filtered.slice(start, end);
|
||||
|
||||
return {
|
||||
data,
|
||||
total
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,397 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
||||
import { Skeleton } from '@/components/ui/form';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const recentVideos = ref<ModelVideo[]>([]);
|
||||
|
||||
// Mock stats data (in real app, fetch from API)
|
||||
const stats = ref({
|
||||
totalVideos: 0,
|
||||
totalViews: 0,
|
||||
storageUsed: 0,
|
||||
storageLimit: 10737418240, // 10GB in bytes
|
||||
uploadsThisMonth: 0
|
||||
});
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
title: 'Upload Video',
|
||||
description: 'Upload a new video to your library',
|
||||
icon: 'i-heroicons-cloud-arrow-up',
|
||||
color: 'bg-gradient-to-br from-primary/20 to-primary/5',
|
||||
iconColor: 'text-primary',
|
||||
onClick: () => router.push('/upload')
|
||||
},
|
||||
{
|
||||
title: 'Video Library',
|
||||
description: 'Browse all your videos',
|
||||
icon: 'i-heroicons-film',
|
||||
color: 'bg-gradient-to-br from-blue-100 to-blue-50',
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => router.push('/video')
|
||||
},
|
||||
{
|
||||
title: 'Analytics',
|
||||
description: 'Track performance & insights',
|
||||
icon: 'i-heroicons-chart-bar',
|
||||
color: 'bg-gradient-to-br from-purple-100 to-purple-50',
|
||||
iconColor: 'text-purple-600',
|
||||
onClick: () => {}
|
||||
},
|
||||
{
|
||||
title: 'Manage Plan',
|
||||
description: 'Upgrade or change your plan',
|
||||
icon: 'i-heroicons-credit-card',
|
||||
color: 'bg-gradient-to-br from-orange-100 to-orange-50',
|
||||
iconColor: 'text-orange-600',
|
||||
onClick: () => router.push('/plans')
|
||||
},
|
||||
];
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Fetch recent videos
|
||||
const response = await client.videos.videosList({ page: 1, limit: 5 });
|
||||
const body = response.data as any;
|
||||
|
||||
if (body.data && Array.isArray(body.data)) {
|
||||
recentVideos.value = body.data;
|
||||
stats.value.totalVideos = body.data.length;
|
||||
} else if (Array.isArray(body)) {
|
||||
recentVideos.value = body;
|
||||
stats.value.totalVideos = body.length;
|
||||
}
|
||||
|
||||
// Calculate mock stats
|
||||
stats.value.totalViews = recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0);
|
||||
stats.value.storageUsed = recentVideos.value.reduce((sum, v) => sum + (v.size || 0), 0);
|
||||
stats.value.uploadsThisMonth = recentVideos.value.filter(v => {
|
||||
const uploadDate = new Date(v.created_at || '');
|
||||
const now = new Date();
|
||||
return uploadDate.getMonth() === now.getMonth() && uploadDate.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err);
|
||||
} finally {
|
||||
loading.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];
|
||||
};
|
||||
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '0:00';
|
||||
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('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusClass = (status?: string) => {
|
||||
switch(status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const storagePercentage = computed(() => {
|
||||
return Math.round((stats.value.storageUsed / stats.value.storageLimit) * 100);
|
||||
});
|
||||
|
||||
const storageBreakdown = computed(() => {
|
||||
const videoSize = stats.value.storageUsed;
|
||||
const thumbSize = stats.value.totalVideos * 300 * 1024; // ~300KB per thumbnail
|
||||
const otherSize = stats.value.totalVideos * 100 * 1024; // ~100KB other files
|
||||
const total = videoSize + thumbSize + otherSize;
|
||||
|
||||
return [
|
||||
{ label: 'Videos', size: videoSize, percentage: (videoSize / total) * 100, color: 'bg-primary' },
|
||||
{ label: 'Thumbnails & Assets', size: thumbSize, percentage: (thumbSize / total) * 100, color: 'bg-blue-500' },
|
||||
{ label: 'Other Files', size: otherSize, percentage: (otherSize / total) * 100, color: 'bg-gray-400' },
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchDashboardData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Add video</div>
|
||||
</template>
|
||||
<div class="dashboard-overview">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="Welcome back! Here's what's happening with your videos."
|
||||
:breadcrumbs="[
|
||||
{ label: 'Dashboard' }
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="animate-pulse">
|
||||
<!-- Stats Grid Skeleton -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div v-for="i in 4" :key="i" class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="space-y-2">
|
||||
<Skeleton width="5rem" height="1rem" class="mb-2 rounded" />
|
||||
<Skeleton width="8rem" height="2rem" class="rounded" />
|
||||
</div>
|
||||
<Skeleton width="3rem" height="3rem" class="rounded-full" />
|
||||
</div>
|
||||
<Skeleton width="4rem" height="1rem" class="rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Skeleton -->
|
||||
<div class="mb-8">
|
||||
<Skeleton width="10rem" height="1.5rem" class="mb-4 rounded" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton width="3rem" height="3rem" class="mb-4 rounded-full" />
|
||||
<Skeleton width="8rem" height="1.25rem" class="mb-2 rounded" />
|
||||
<Skeleton width="100%" height="1rem" class="rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Videos Skeleton -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Skeleton width="8rem" height="1.5rem" class="rounded" />
|
||||
<Skeleton width="5rem" height="1rem" class="rounded" />
|
||||
</div>
|
||||
<div 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">
|
||||
<Skeleton width="4rem" height="2.5rem" class="rounded" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<Skeleton width="30%" height="1rem" class="rounded" />
|
||||
<Skeleton width="20%" height="0.8rem" class="rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Total Videos"
|
||||
:value="stats.totalVideos"
|
||||
icon="i-heroicons-film"
|
||||
color="primary"
|
||||
:trend="{ value: 12, isPositive: true }"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Total Views"
|
||||
:value="stats.totalViews.toLocaleString()"
|
||||
icon="i-heroicons-eye"
|
||||
color="info"
|
||||
:trend="{ value: 8, isPositive: true }"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Storage Used"
|
||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`"
|
||||
icon="i-heroicons-server"
|
||||
color="warning"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Uploads This Month"
|
||||
:value="stats.uploadsThisMonth"
|
||||
icon="i-heroicons-arrow-up-tray"
|
||||
color="success"
|
||||
:trend="{ value: 25, isPositive: true }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button
|
||||
v-for="action in quickActions"
|
||||
:key="action.title"
|
||||
@click="action.onClick"
|
||||
:class="[
|
||||
'p-6 rounded-xl text-left transition-all duration-200',
|
||||
'border border-gray-200 hover:border-primary hover:shadow-lg',
|
||||
'group press-animated',
|
||||
action.color
|
||||
]"
|
||||
>
|
||||
<div :class="['w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-white/80', action.iconColor]">
|
||||
<span :class="[action.icon, 'w-6 h-6']" />
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ action.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Videos -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">Recent Videos</h2>
|
||||
<router-link
|
||||
to="/video"
|
||||
class="text-sm text-primary hover:underline font-medium flex items-center gap-1"
|
||||
>
|
||||
View all
|
||||
<span class="i-heroicons-arrow-right w-4 h-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="recentVideos.length === 0" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-4">
|
||||
<span class="i-heroicons-film w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">No videos yet</p>
|
||||
<router-link
|
||||
to="/upload"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<span class="i-heroicons-plus w-5 h-5" />
|
||||
Upload your first video
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Video</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="video in recentVideos" :key="video.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-16 h-10 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
||||
<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">
|
||||
<span class="i-heroicons-film text-gray-400 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ video.description || 'No description' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span :class="['px-2 py-1 text-xs font-medium rounded-full', getStatusClass(video.status)]">
|
||||
{{ video.status || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDate(video.created_at) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
|
||||
<span class="i-heroicons-pencil w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share">
|
||||
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-red-100 rounded transition-colors" title="Delete">
|
||||
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Usage -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Storage Usage</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
{{ formatBytes(stats.storageUsed) }} of {{ formatBytes(stats.storageLimit) }} used
|
||||
</span>
|
||||
<span class="text-sm font-medium" :class="storagePercentage > 80 ? 'text-danger' : 'text-gray-700'">
|
||||
{{ storagePercentage }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-500 rounded-full"
|
||||
:class="storagePercentage > 80 ? 'bg-danger' : 'bg-primary'"
|
||||
:style="{ width: `${storagePercentage}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="item in storageBreakdown"
|
||||
:key="item.label"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div :class="['w-3 h-3 rounded-sm', item.color]" />
|
||||
<span class="text-gray-700">{{ item.label }}</span>
|
||||
</div>
|
||||
<span class="text-gray-500">{{ formatBytes(item.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="storagePercentage > 80" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex gap-2">
|
||||
<span class="i-heroicons-exclamation-triangle w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800">Storage running low</p>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
Consider upgrading your plan to get more storage.
|
||||
<router-link to="/plans" class="underline font-medium">View plans</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Toast />
|
||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
||||
class="flex flex-col gap-4 w-full">
|
||||
<Form
|
||||
:initialValues="initialValues"
|
||||
:validators="validators"
|
||||
@submit="onFormSubmit"
|
||||
class="flex flex-col gap-4 w-full"
|
||||
>
|
||||
<div class="text-sm text-gray-600 mb-2">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
||||
<InputText name="email" type="email" placeholder="you@example.com" fluid />
|
||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.email.error?.message }}</Message>
|
||||
</div>
|
||||
<Field name="email" label="Email address">
|
||||
<template #default="{ value, error, isInvalid }">
|
||||
<Input name="email" type="email" placeholder="you@example.com" :modelValue="value" />
|
||||
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||
</template>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" label="Send Reset Link" fluid />
|
||||
<Button type="submit" label="Send Reset Link" />
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<router-link to="/login" replace
|
||||
@@ -31,36 +35,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { client } from '@/api/client';
|
||||
import { Button, Field, Form, Input, Toast } from '@/components/ui/form';
|
||||
import { inject, reactive } from 'vue';
|
||||
|
||||
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { forgotPassword } from '@/lib/firebase';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const toast = useToast();
|
||||
const toast = inject<{ add: (t: any) => void }>('toast');
|
||||
|
||||
const initialValues = reactive({
|
||||
email: ''
|
||||
});
|
||||
|
||||
const resolver = zodResolver(
|
||||
z.object({
|
||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
|
||||
})
|
||||
);
|
||||
|
||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) {
|
||||
forgotPassword(values.email).then(() => {
|
||||
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
||||
}).catch(() => {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
|
||||
});
|
||||
}
|
||||
const validators = {
|
||||
email: [
|
||||
(value: string) => !value ? 'Email is required.' : undefined,
|
||||
(value: string) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'Invalid email address.' : undefined,
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
const onFormSubmit = (values: Record<string, any>) => {
|
||||
client.auth.forgotPasswordCreate({ email: values.email })
|
||||
.then(() => {
|
||||
toast?.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
toast?.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
<template>
|
||||
<div class="w-full max-w-md bg-white p-8 rounded-xl border border-primary m-auto overflow-hidden">
|
||||
<div class="text-center mb-8">
|
||||
<router-link to="/" class="inline-flex items-center justify-center w-12 h-12 mb-4">
|
||||
<img class="w-12 h-12" src="/apple-touch-icon.png" alt="Logo" />
|
||||
</router-link>
|
||||
<h2 class="text-2xl font-bold text-gray-900">
|
||||
{{ content[route.name as keyof typeof content]?.title || '' }}
|
||||
</h2>
|
||||
<p class="text-gray-500 text-sm mt-1">
|
||||
{{ content[route.name as keyof typeof content]?.subtitle || '' }}
|
||||
</p>
|
||||
<vue-head :input="{
|
||||
title: content[route.name as keyof typeof content]?.headTitle || 'Authentication',
|
||||
meta: [
|
||||
{ name: 'description', content: content[route.name as keyof typeof content]?.subtitle || '' }
|
||||
]
|
||||
}" />
|
||||
<div class="mx-a max-w-md w-full min-h-screen flex flex-col items-center px-4 justify-center">
|
||||
<div class="w-full h-6 mb-10"></div>
|
||||
<div
|
||||
class=":uno: w-full shadow-xl bg-white p-6 rounded-xl relative before:(content-[''] absolute inset-[-5px] translate-0 z-[-1] opacity-50 rounded-xl bg-[linear-gradient(135deg,var(--glow-stop-1)_0,var(--glow-stop-2)_25%,var(--glow-stop-3)_50%,var(--glow-stop-4)_75%,var(--glow-stop-5)_100%)] animate-[glow-enter-blur_1s_ease_.5s_both]) after:(content-[''] absolute inset-[-1px] translate-0 z-[-1] opacity-50 rounded-xl bg-[linear-gradient(135deg,transparent_0,transparent_34%,transparent_49%,#fff_57%,#fff_64%,var(--glow-stop-1)_66%,var(--glow-stop-2)_75%,var(--glow-stop-3)_83%,var(--glow-stop-4)_92%,var(--glow-stop-5)_100%)] bg-[length:300%_300%] bg-[position:0_0] bg-no-repeat transition-background-position duration-800 ease animate-[glow-enter-stroke_.5s_ease_.5s_both])">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-xl font-medium text-gray-900">
|
||||
{{ content[route.name as keyof typeof content]?.title || '' }}
|
||||
</h2>
|
||||
<vue-head :input="{
|
||||
title: content[route.name as keyof typeof content]?.headTitle || 'Authentication',
|
||||
meta: [
|
||||
{ name: 'description', content: content[route.name as keyof typeof content]?.subtitle || '' }
|
||||
]
|
||||
}" />
|
||||
</div>
|
||||
<router-view />
|
||||
</div>
|
||||
<router-view />
|
||||
<router-link to="/" class="inline-flex items-center justify-center w-6 h-6 mt-10 group w-full">
|
||||
<img class="w-6 h-6" src="/apple-touch-icon.png" alt="Logo" /> <span
|
||||
class="text-[#6a6a6a] font-medium group-hover:text-gray-900">EcoStream</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import vueHead from "@/components/VueHead";
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const content = {
|
||||
login: {
|
||||
headTitle: "Login to your account",
|
||||
title: 'Welcome back',
|
||||
title: 'Sign in to your dashboard',
|
||||
subtitle: 'Please enter your details to sign in.'
|
||||
},
|
||||
signup: {
|
||||
|
||||
@@ -1,39 +1,58 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Toast />
|
||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
||||
class="flex flex-col gap-4 w-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email</label>
|
||||
<InputText name="email" type="text" placeholder="user@example.com" fluid
|
||||
:disabled="auth.loading" />
|
||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.email.error?.message }}</Message>
|
||||
</div>
|
||||
<Form
|
||||
:initialValues="initialValues"
|
||||
:validators="validators"
|
||||
@submit="onFormSubmit"
|
||||
class="flex flex-col gap-4 w-full"
|
||||
>
|
||||
<Field name="email" label="Email">
|
||||
<template #default="{ value, error, isInvalid }">
|
||||
<Input
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="Enter your email"
|
||||
:modelValue="value"
|
||||
:disabled="auth.loading"
|
||||
/>
|
||||
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||
</template>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
||||
<Password name="password" placeholder="••••••••" :feedback="false" toggleMask fluid
|
||||
:inputStyle="{ width: '100%' }" :disabled="auth.loading" />
|
||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.password.error?.message }}</Message>
|
||||
</div>
|
||||
<Field name="password" label="Password">
|
||||
<template #default="{ value, error, isInvalid }">
|
||||
<Input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
:modelValue="value"
|
||||
:disabled="auth.loading"
|
||||
/>
|
||||
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||
</template>
|
||||
</Field>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox inputId="remember-me" name="rememberMe" binary :disabled="auth.loading" />
|
||||
<input
|
||||
id="remember-me"
|
||||
type="checkbox"
|
||||
v-model="initialValues.rememberMe"
|
||||
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary/20"
|
||||
/>
|
||||
<label for="remember-me" class="text-sm text-gray-900">Remember me</label>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<router-link to="/forgot"
|
||||
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Forgot
|
||||
class="text-blue-600 hover:text-blue-500 hover:underline">Forgot
|
||||
password?</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid :loading="auth.loading" />
|
||||
<Button type="submit" :label="auth.loading ? 'Signing in...' : 'Sign in'" :loading="auth.loading" />
|
||||
|
||||
<div class="relative my-4">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
@@ -42,38 +61,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outlined" severity="secondary"
|
||||
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
|
||||
</svg>
|
||||
Google
|
||||
<Button type="button" variant="outlined" label="Google" :loading="auth.loading" @click="loginWithGoogle">
|
||||
<template #default>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up
|
||||
for free</router-link>
|
||||
</p>
|
||||
<div class="mt-2 flex flex-col items-center justify-center gap-1 text-sm text-gray-600">
|
||||
<p class="text-center text-sm text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { Button, Field, Form, Input, Toast } from '@/components/ui/form';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useToast } from "primevue/usetoast";
|
||||
const t = useToast();
|
||||
import { inject, reactive, watch } from 'vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
// const $form = Form.useFormContext();
|
||||
const toast = inject<{ add: (t: any) => void }>('toast');
|
||||
|
||||
watch(() => auth.error, (newError) => {
|
||||
if (newError) {
|
||||
t.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
|
||||
if (newError && toast) {
|
||||
toast.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -83,18 +99,20 @@ const initialValues = reactive({
|
||||
rememberMe: false
|
||||
});
|
||||
|
||||
const resolver = zodResolver(
|
||||
z.object({
|
||||
email: z.string().min(1, { message: 'Email or username is required.' }),
|
||||
password: z.string().min(1, { message: 'Password is required.' })
|
||||
})
|
||||
);
|
||||
const validators = {
|
||||
email: [
|
||||
(value: string) => !value ? 'Email or username is required.' : undefined,
|
||||
],
|
||||
password: [
|
||||
(value: string) => !value ? 'Password is required.' : undefined,
|
||||
],
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) auth.login(values.email, values.password);
|
||||
const onFormSubmit = async (values: Record<string, any>) => {
|
||||
auth.login(values.email, values.password);
|
||||
};
|
||||
|
||||
const loginWithGoogle = () => {
|
||||
auth.loginWithGoogle();
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,53 +1,49 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
|
||||
class="flex flex-col gap-4 w-full">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
|
||||
<InputText name="name" placeholder="John Doe" fluid />
|
||||
<Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.name.error?.message }}</Message>
|
||||
</div>
|
||||
<Form
|
||||
:initialValues="initialValues"
|
||||
:validators="validators"
|
||||
@submit="onFormSubmit"
|
||||
class="flex flex-col gap-4 w-full"
|
||||
>
|
||||
<Field name="name" label="Full Name">
|
||||
<template #default="{ value, error, isInvalid }">
|
||||
<Input name="name" type="text" placeholder="John Doe" :modelValue="value" />
|
||||
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||
</template>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
||||
<InputText name="email" type="email" placeholder="you@example.com" fluid />
|
||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.email.error?.message }}</Message>
|
||||
</div>
|
||||
<Field name="email" label="Email address">
|
||||
<template #default="{ value, error, isInvalid }">
|
||||
<Input name="email" type="email" placeholder="you@example.com" :modelValue="value" />
|
||||
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||
</template>
|
||||
</Field>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
||||
<Password name="password" placeholder="Create a password" :feedback="true" toggleMask fluid
|
||||
:inputStyle="{ width: '100%' }" />
|
||||
<small class="text-gray-500">Must be at least 8 characters.</small>
|
||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.password.error?.message }}</Message>
|
||||
</div>
|
||||
<Field name="password" label="Password">
|
||||
<template #default="{ value, error, isInvalid }">
|
||||
<Input name="password" type="password" placeholder="Create a password" :modelValue="value" />
|
||||
<small class="text-gray-500">Must be at least 8 characters.</small>
|
||||
<div v-if="isInvalid" class="text-xs text-red-600 mt-1">{{ error }}</div>
|
||||
</template>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" label="Create Account" fluid />
|
||||
<Button type="submit" label="Create Account" />
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
Already have an account?
|
||||
<router-link to="/login" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign
|
||||
in</router-link>
|
||||
<router-link to="/login" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign in</router-link>
|
||||
</p>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue';
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
||||
import { Button, Field, Form, Input } from '@/components/ui/form';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const toast = useToast();
|
||||
|
||||
const initialValues = reactive({
|
||||
name: '',
|
||||
@@ -55,19 +51,21 @@ const initialValues = reactive({
|
||||
password: ''
|
||||
});
|
||||
|
||||
const resolver = zodResolver(
|
||||
z.object({
|
||||
name: z.string().min(1, { message: 'Name is required.' }),
|
||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
|
||||
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
|
||||
})
|
||||
);
|
||||
|
||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) {
|
||||
auth.register(values.name, values.email, values.password).catch(() => {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
|
||||
});
|
||||
}
|
||||
const validators = {
|
||||
name: [
|
||||
(value: string) => !value ? 'Name is required.' : undefined,
|
||||
],
|
||||
email: [
|
||||
(value: string) => !value ? 'Email is required.' : undefined,
|
||||
(value: string) => !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? 'Invalid email address.' : undefined,
|
||||
],
|
||||
password: [
|
||||
(value: string) => !value ? 'Password is required.' : undefined,
|
||||
(value: string) => value.length < 8 ? 'Password must be at least 8 characters.' : undefined,
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
const onFormSubmit = (values: Record<string, any>) => {
|
||||
auth.register(values.name, values.email, values.password);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,117 +1,154 @@
|
||||
<template>
|
||||
<nav class="fixed w-full z-50 glass-nav transition-all duration-300" id="navbar">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center gap-2 cursor-pointer" onclick="window.scrollTo(0,0)"><img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />
|
||||
<span class="font-bold text-xl tracking-tight text-slate-900">EcoStream</span>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center space-x-8">
|
||||
<a href="#features" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Features</a>
|
||||
<a href="#pricing" class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Pricing</a>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<RouterLink to="/login" class="text-sm font-semibold text-slate-600 hover:text-slate-900 cursor-pointer">Log in</RouterLink>
|
||||
<RouterLink to="/sign-up" class="bg-slate-900 hover:bg-black text-white px-5 py-2.5 rounded-lg text-sm font-semibold cursor-pointer">
|
||||
Start for free
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<section class=":m: relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden min-h-svh flex">
|
||||
<!-- <div class="absolute inset-0 bg-grid-pattern opacity-[0.4] -z-10"></div> -->
|
||||
<div
|
||||
class=":m: absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[800px] h-[800px] bg-primary-light/40 rounded-full blur-3xl -z-10 mix-blend-multiply animate-pulse duration-1000">
|
||||
</div>
|
||||
<div
|
||||
class=":m: absolute bottom-0 left-0 translate-y-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-teal-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply">
|
||||
</div>
|
||||
</nav>
|
||||
<section class="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
|
||||
<div class="absolute inset-0 opacity-[0.4] -z-10"></div>
|
||||
<div class="absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[800px] h-[800px] bg-brand-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply"></div>
|
||||
<div class="absolute bottom-0 left-0 translate-y-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-teal-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 class="text-5xl md:text-7xl font-extrabold tracking-tight text-slate-900 mb-6 leading-[1.1]">
|
||||
|
||||
<div class="max-w-7xl m-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1
|
||||
class="text-5xl md:text-7xl font-extrabold tracking-tight text-slate-900 mb-6 leading-[1.1] animate-backwards">
|
||||
Video infrastructure for <br>
|
||||
<span class="text-gradient">modern internet.</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-slate-500 max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
Seamlessly host, encode, and stream video with our developer-first API.
|
||||
|
||||
<p class="text-xl text-slate-500 max-w-2xl mx-auto mb-10 leading-relaxed animate-backwards delay-50">
|
||||
Seamlessly host, encode, and stream video with our developer-first API.
|
||||
Optimized for speed, built for scale.
|
||||
</p>
|
||||
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<RouterLink to="/get-started" class="flex btn btn-secondary !rounded-xl !p-4 press-animated">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="46 -286 524 580"><path d="M56 284v-560L560 4 56 284z" fill="#fff"/></svg>
|
||||
<RouterLink to="/get-started" class="flex btn btn-success !rounded-xl !p-4 press-animated">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="46 -286 524 580">
|
||||
<path d="M56 284v-560L560 4 56 284z" fill="#fff" />
|
||||
</svg>
|
||||
Get Started
|
||||
</RouterLink>
|
||||
<RouterLink to="/docs" class="flex btn btn-outline-primary !rounded-xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -261 468 503"><path d="M256-139V72c0 18-14 32-32 32s-32-14-32-32v-211l-41 42c-13 12-33 12-46 0-12-13-12-33 0-46l96-96c13-12 33-12 46 0l96 96c12 13 12 33 0 46-13 12-33 12-46 0l-41-42zm-32 291c44 0 80-36 80-80h80c35 0 64 29 64 64v32c0 35-29 64-64 64H64c-35 0-64-29-64-64v-32c0-35 29-64 64-64h80c0 44 36 80 80 80zm144 24c13 0 24-11 24-24s-11-24-24-24-24 11-24 24 11 24 24 24z" fill="#14a74b"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="28" viewBox="0 0 596 468">
|
||||
<path
|
||||
d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z"
|
||||
fill="#14a74b" />
|
||||
<path
|
||||
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
|
||||
fill="#fff" />
|
||||
</svg>
|
||||
Upload video
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="features" class="py-24 bg-white">
|
||||
<section id="features" class="py-24 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-16 md:text-center max-w-3xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-slate-900 mb-4">Everything you need to ship video</h2>
|
||||
<p class="text-lg text-slate-500">Focus on building your product. We'll handle the complex video infrastructure.</p>
|
||||
<p class="text-lg text-slate-500">Focus on building your product. We'll handle the complex video
|
||||
infrastructure.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="md:col-span-2 bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-primary/60 transition-all group overflow-hidden relative">
|
||||
<div
|
||||
class=":m: md:col-span-2 bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-primary/60 transition-all group overflow-hidden relative">
|
||||
<div class="relative z-10">
|
||||
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center mb-6 border border-slate-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="532" viewBox="-8 -258 529 532"><path d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z" fill="#059669"/></svg>
|
||||
</div>
|
||||
<div
|
||||
class="w-12 h-12 bg-white rounded-xl flex items-center justify-center mb-6 border border-slate-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="532" viewBox="-8 -258 529 532">
|
||||
<path
|
||||
d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z"
|
||||
fill="#059669" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">Global Edge Network</h3>
|
||||
<p class="text-slate-500 max-w-md">Content delivered from 200+ PoPs worldwide. Automatic region selection ensures the lowest latency for every viewer.</p>
|
||||
<p class="text-slate-500 max-w-md">Content delivered from 200+ PoPs worldwide. Automatic region
|
||||
selection ensures the lowest latency for every viewer.</p>
|
||||
</div>
|
||||
<div class="absolute right-0 bottom-0 opacity-10 translate-x-1/4 translate-y-1/4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-10 -258 532 532"><path d="M464 8c0-19-3-38-8-56l-27-5c-8-2-15 2-19 9-6 11-19 17-31 13l-14-5c-8-2-17 0-22 5-4 4-4 10 0 14l33 33c5 5 8 12 8 19 0 12-8 23-20 26l-6 1c-3 1-6 5-6 9v12c0 13-4 27-13 38l-25 34c-6 8-16 13-26 13-18 0-32-14-32-32V88c0-9-7-16-16-16h-32c-26 0-48-22-48-48V-4c0-13 6-24 16-32l39-30c6-4 13-6 20-6 3 0 7 1 10 2l32 10c7 3 15 3 22 1l36-9c10-2 17-11 17-22 0-8-5-16-13-20l-29-15c-3-2-8-1-11 2l-4 4c-4 4-11 7-17 7-4 0-8-1-11-3l-15-7c-7-4-15-2-20 4l-13 17c-6 7-16 8-22 1-3-2-5-6-5-10v-41c0-6-1-11-4-16l-10-18C102-154 48-79 48 8c0 115 93 208 208 208S464 123 464 8zM0 8c0-141 115-256 256-256S512-133 512 8 397 264 256 264 0 149 0 8z" fill="#1e3050"/></svg>
|
||||
</div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-10 -258 532 532">
|
||||
<path
|
||||
d="M464 8c0-19-3-38-8-56l-27-5c-8-2-15 2-19 9-6 11-19 17-31 13l-14-5c-8-2-17 0-22 5-4 4-4 10 0 14l33 33c5 5 8 12 8 19 0 12-8 23-20 26l-6 1c-3 1-6 5-6 9v12c0 13-4 27-13 38l-25 34c-6 8-16 13-26 13-18 0-32-14-32-32V88c0-9-7-16-16-16h-32c-26 0-48-22-48-48V-4c0-13 6-24 16-32l39-30c6-4 13-6 20-6 3 0 7 1 10 2l32 10c7 3 15 3 22 1l36-9c10-2 17-11 17-22 0-8-5-16-13-20l-29-15c-3-2-8-1-11 2l-4 4c-4 4-11 7-17 7-4 0-8-1-11-3l-15-7c-7-4-15-2-20 4l-13 17c-6 7-16 8-22 1-3-2-5-6-5-10v-41c0-6-1-11-4-16l-10-18C102-154 48-79 48 8c0 115 93 208 208 208S464 123 464 8zM0 8c0-141 115-256 256-256S512-133 512 8 397 264 256 264 0 149 0 8z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:row-span-2 bg-slate-900 rounded-2xl p-8 text-white relative overflow-hidden group">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-slate-800/50 to-transparent"></div>
|
||||
<div class=":m: md:row-span-2 bg-slate-900 rounded-2xl p-8 text-white relative overflow-hidden group">
|
||||
<div class=":m: absolute inset-0 bg-gradient-to-b from-slate-800/50 to-transparent"></div>
|
||||
<div class="relative z-10">
|
||||
<div class="w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center mb-6 backdrop-blur-sm border border-white/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -146 468 384"><path d="M392-136c-31 0-56 25-56 56v280c0 16 13 28 28 28h28c31 0 56-25 56-56V-80c0-31-25-56-56-56zM168 4c0-31 25-56 56-56h28c16 0 28 13 28 28v224c0 16-12 28-28 28h-56c-15 0-28-12-28-28V4zM0 88c0-31 25-56 56-56h28c16 0 28 13 28 28v140c0 16-12 28-28 28H56c-31 0-56-25-56-56V88z" fill="#fff"/></svg>
|
||||
<div
|
||||
class=":m: w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center mb-6 backdrop-blur-sm border border-white/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -146 468 384">
|
||||
<path
|
||||
d="M392-136c-31 0-56 25-56 56v280c0 16 13 28 28 28h28c31 0 56-25 56-56V-80c0-31-25-56-56-56zM168 4c0-31 25-56 56-56h28c16 0 28 13 28 28v224c0 16-12 28-28 28h-56c-15 0-28-12-28-28V4zM0 88c0-31 25-56 56-56h28c16 0 28 13 28 28v140c0 16-12 28-28 28H56c-31 0-56-25-56-56V88z"
|
||||
fill="#fff" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold mb-2">Live Streaming API</h3>
|
||||
<p class="text-slate-400 text-sm leading-relaxed mb-8">Scale to millions of concurrent viewers with ultra-low latency. RTMP ingest and HLS playback supported natively.</p>
|
||||
|
||||
<p class="text-slate-400 text-sm leading-relaxed mb-8">Scale to millions of concurrent viewers
|
||||
with ultra-low latency. RTMP ingest and HLS playback supported natively.</p>
|
||||
|
||||
<!-- Visual -->
|
||||
<div class="bg-slate-800/50 rounded-lg p-4 border border-white/5 font-mono text-xs text-brand-300">
|
||||
<div
|
||||
class="bg-slate-800/50 rounded-lg p-4 border border-white/5 font-mono text-xs text-brand-300">
|
||||
<div class="flex justify-between items-center mb-3 border-b border-white/5 pb-2">
|
||||
<span class="text-slate-500">Live Status</span>
|
||||
<span class="flex items-center gap-1.5 text-red-500 text-[10px] uppercase font-bold tracking-wider animate-pulse"><span class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> On Air</span>
|
||||
<span
|
||||
class=":m: flex items-center gap-1.5 text-red-500 text-[10px] uppercase font-bold tracking-wider animate-pulse"><span
|
||||
class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> On Air</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between"><span class="text-slate-400">Bitrate:</span> <span class="text-white">6000 kbps</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-400">FPS:</span> <span class="text-white">60</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-400">Latency:</span> <span class="text-brand-400">~2s</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-400">Bitrate:</span> <span
|
||||
class="text-white">6000 kbps</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-400">FPS:</span> <span
|
||||
class="text-white">60</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-400">Latency:</span> <span
|
||||
class="text-brand-400">~2s</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Standard Feature -->
|
||||
<div class="bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-brand-200 transition-all group hover:shadow-lg hover:shadow-brand-500/5">
|
||||
<div class="w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-purple-600 border border-slate-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 570 570"><path d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z" fill="#a6acb9"/><path d="M170 26c14-15 36-15 50 0l18 18c15 14 15 36 0 50l-18 18c-14 15-36 15-50 0l-18-18c-15-14-15-36 0-50l18-18zm35 41c5-5 5-14 0-19-6-5-14-5-20 0l-11 12c-5 5-5 13 0 19 5 5 14 5 19 0l12-12zm204 342c21-21 55-21 76 0l18 18c21 21 21 55 0 76l-18 18c-21 21-55 21-76 0l-18-18c-21-21-21-55 0-76l18-18zm38 38c5-5 5-14 0-19s-14-5-19 0l-18 18c-5 5-5 14 0 19s14 5 19 0l18-18zM113 170c-15-15-37-15-51 0l-18 18c-14 14-14 36 0 50l18 18c14 15 37 15 51 0l18-18c14-14 14-36 0-50l-18-18zm-16 41-12 12c-5 5-14 5-19 0-5-6-5-14 0-20l11-11c6-5 14-5 20 0 5 5 5 14 0 19zM485 31c-21-21-55-21-76 0l-39 39c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l39-39c21-21 21-55 0-76l-54-54zm-38 38-39 39c-5 5-14 5-19 0s-5-14 0-19l39-39c5-5 14-5 19 0s5 14 0 19zm-49 233c21-21 21-55 0-76l-54-54c-21-21-55-21-76 0L31 409c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l237-237zm-92-92L69 447c-5 5-14 5-19 0s-5-14 0-19l237-237c5-5 14-5 19 0s5 14 0 19z" fill="#1e3050"/></svg>
|
||||
<div
|
||||
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
|
||||
<div
|
||||
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-purple-600 border border-slate-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 570 570">
|
||||
<path
|
||||
d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z"
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M170 26c14-15 36-15 50 0l18 18c15 14 15 36 0 50l-18 18c-14 15-36 15-50 0l-18-18c-15-14-15-36 0-50l18-18zm35 41c5-5 5-14 0-19-6-5-14-5-20 0l-11 12c-5 5-5 13 0 19 5 5 14 5 19 0l12-12zm204 342c21-21 55-21 76 0l18 18c21 21 21 55 0 76l-18 18c-21 21-55 21-76 0l-18-18c-21-21-21-55 0-76l18-18zm38 38c5-5 5-14 0-19s-14-5-19 0l-18 18c-5 5-5 14 0 19s14 5 19 0l18-18zM113 170c-15-15-37-15-51 0l-18 18c-14 14-14 36 0 50l18 18c14 15 37 15 51 0l18-18c14-14 14-36 0-50l-18-18zm-16 41-12 12c-5 5-14 5-19 0-5-6-5-14 0-20l11-11c6-5 14-5 20 0 5 5 5 14 0 19zM485 31c-21-21-55-21-76 0l-39 39c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l39-39c21-21 21-55 0-76l-54-54zm-38 38-39 39c-5 5-14 5-19 0s-5-14 0-19l39-39c5-5 14-5 19 0s5 14 0 19zm-49 233c21-21 21-55 0-76l-54-54c-21-21-55-21-76 0L31 409c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l237-237zm-92-92L69 447c-5 5-14 5-19 0s-5-14 0-19l237-237c5-5 14-5 19 0s5 14 0 19z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">Instant Encoding</h3>
|
||||
<p class="text-slate-500 text-sm">Upload raw files and get optimized HLS/DASH streams in seconds.</p>
|
||||
<p class="text-slate-500 text-sm">Upload raw files and get optimized HLS/DASH streams in seconds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Standard Feature -->
|
||||
<div class="bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-brand-200 transition-all group hover:shadow-lg hover:shadow-brand-500/5">
|
||||
<div class="w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-orange-600 border border-slate-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -226 532 468"><path d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z" fill="#1e3050"/></svg>
|
||||
<div
|
||||
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
|
||||
<div
|
||||
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-orange-600 border border-slate-100">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -226 532 468">
|
||||
<path
|
||||
d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-slate-900 mb-2">Deep Analytics</h3>
|
||||
<p class="text-slate-500 text-sm">Session-level insights, quality of experience (QoE) metrics, and more.</p>
|
||||
<p class="text-slate-500 text-sm">Session-level insights, quality of experience (QoE) metrics, and
|
||||
more.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Pricing -->
|
||||
<!-- Pricing -->
|
||||
<section id="pricing" class="py-24 border-t border-slate-100 bg-white">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
@@ -120,8 +157,12 @@
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
|
||||
<div v-for="pack in pricing.packs" :key="pack.name" :class="cn(':uno: p-8 rounded-2xl relative overflow-hidden hover:border-primary transition-colors flex flex-col justify-between', pack.tag == 'POPULAR' ? 'border-primary/80 border-2' : 'border-slate-200 border')" :style="{background: pack.bg}">
|
||||
<div v-if="pack.tag" class="absolute top-0 right-0 bg-primary/80 text-white text-xs font-bold px-3 py-1 rounded-bl-lg uppercase">{{ pack.tag }}</div>
|
||||
<div v-for="pack in pricing.packs" :key="pack.name"
|
||||
:class="cn(':uno: p-8 rounded-2xl relative overflow-hidden hover:border-primary transition-colors flex flex-col justify-between', pack.tag == 'POPULAR' ? 'border-primary/80 border-2' : 'border-slate-200 border')"
|
||||
:style="{ background: pack.bg }">
|
||||
<div v-if="pack.tag"
|
||||
class=":m: absolute top-0 right-0 bg-primary/80 text-white text-xs font-bold px-3 py-1 rounded-bl-lg uppercase">
|
||||
{{ pack.tag }}</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-900 text-xl mb-2">{{ pack.name }}</h3>
|
||||
<div class="flex items-baseline gap-1 mb-6">
|
||||
@@ -130,105 +171,61 @@
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8 text-sm text-slate-600">
|
||||
<li v-for="value in pack.features" :key="value" class="flex items-center gap-3"><Check-Icon class="fas fa-check text-brand-500"/> {{ value }}</li>
|
||||
<li v-for="value in pack.features" :key="value" class="flex items-center gap-3"><Check-Icon
|
||||
class="fas fa-check text-brand-500" /> {{ value }}</li>
|
||||
</ul>
|
||||
<router-link to="/sign-up" :class="cn('btn flex justify-center w-full !py-2.5', pack.tag == 'POPULAR' ? 'btn-primary' : 'btn-outline-primary')">{{ pack.buttonText }}</router-link>
|
||||
<router-link to="/sign-up"
|
||||
:class="cn('btn flex justify-center w-full !py-2.5', pack.tag == 'POPULAR' ? 'btn-primary' : 'btn-outline-primary')">{{
|
||||
pack.buttonText }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white border-t border-slate-100 pt-16 pb-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-8 mb-12">
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-6 h-6 bg-brand-600 rounded flex items-center justify-center text-white">
|
||||
<img class="h-6 w-6" src="/apple-touch-icon.png" alt="Logo" />
|
||||
</div>
|
||||
<span class="font-bold text-lg text-slate-900">EcoStream</span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-sm max-w-xs">Building the video layer of the internet. Designed for developers.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Product</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-500">
|
||||
<li><a href="#" class="hover:text-brand-600">Features</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Pricing</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Showcase</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Company</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-500">
|
||||
<li><a href="#" class="hover:text-brand-600">About</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Blog</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Careers</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Legal</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-500">
|
||||
<li><a href="#" class="hover:text-brand-600">Privacy</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Terms</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-8 border-t border-slate-100 text-center text-sm text-slate-400">
|
||||
© 2026 EcoStream Inc. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<Head>
|
||||
<title>EcoStream - Video infrastructure for modern internet</title>
|
||||
<meta name="description" content="Seamlessly host, encode, and stream video with our developer-first API. Optimized for speed, built for scale." />
|
||||
</Head>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Head } from '@unhead/vue/components'
|
||||
import { cn } from '@/lib/utils';
|
||||
const pricing = {
|
||||
title: "Simple, transparent pricing",
|
||||
subtitle: "Choose the plan that fits your needs. No hidden fees.",
|
||||
packs: [
|
||||
{
|
||||
name: "Hobby",
|
||||
price: "$0",
|
||||
features: [
|
||||
"Unlimited upload",
|
||||
"1 Hour of Storage",
|
||||
"Standard Support",
|
||||
],
|
||||
buttonText: "Start Free",
|
||||
tag: "",
|
||||
bg: "#f9fafb",
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "$29",
|
||||
features: [
|
||||
"Ads free player",
|
||||
"Support M3U8",
|
||||
"Unlimited upload",
|
||||
"Custom ads"
|
||||
],
|
||||
buttonText: "Get Started",
|
||||
tag: "POPULAR",
|
||||
bg: "#eff6ff",
|
||||
},
|
||||
{
|
||||
name: "Scale",
|
||||
price: "$99",
|
||||
features: [
|
||||
"5 TB Bandwidth",
|
||||
"500 Hours Storage",
|
||||
"Priority Support"
|
||||
],
|
||||
buttonText: "Contact Sales",
|
||||
tag: "Best Value",
|
||||
bg: "#eef4f7",
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
const pricing = {
|
||||
title: "Simple, transparent pricing",
|
||||
subtitle: "Choose the plan that fits your needs. No hidden fees.",
|
||||
packs: [
|
||||
{
|
||||
name: "Hobby",
|
||||
price: "$0",
|
||||
features: [
|
||||
"Unlimited upload",
|
||||
"1 Hour of Storage",
|
||||
"Standard Support",
|
||||
],
|
||||
buttonText: "Start Free",
|
||||
tag: "",
|
||||
bg: "#f9fafb",
|
||||
},
|
||||
{
|
||||
name: "Pro",
|
||||
price: "$29",
|
||||
features: [
|
||||
"Ads free player",
|
||||
"Support M3U8",
|
||||
"Unlimited upload",
|
||||
"Custom ads"
|
||||
],
|
||||
buttonText: "Get Started",
|
||||
tag: "POPULAR",
|
||||
bg: "#eff6ff",
|
||||
},
|
||||
{
|
||||
name: "Scale",
|
||||
price: "$99",
|
||||
features: [
|
||||
"5 TB Bandwidth",
|
||||
"500 Hours Storage",
|
||||
"Priority Support"
|
||||
],
|
||||
buttonText: "Contact Sales",
|
||||
tag: "Best Value",
|
||||
bg: "#eef4f7",
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
84
src/routes/home/Layout.vue
Normal file
84
src/routes/home/Layout.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<header>
|
||||
<nav class="fixed w-full z-50 glass-nav transition-all duration-300" id="navbar">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<router-link to="/" class="flex items-center gap-2 cursor-pointer">
|
||||
<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />
|
||||
<span class="font-bold text-xl tracking-tight text-slate-900">EcoStream</span>
|
||||
</router-link>
|
||||
<div class="hidden md:flex items-center space-x-8">
|
||||
<a href="#features"
|
||||
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Features</a>
|
||||
<a href="#pricing"
|
||||
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Pricing</a>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center gap-4">
|
||||
<RouterLink to="/login"
|
||||
class="text-sm font-semibold text-slate-600 hover:text-slate-900 cursor-pointer">Log in
|
||||
</RouterLink>
|
||||
<RouterLink to="/sign-up"
|
||||
class="bg-slate-900 hover:bg-black text-white px-5 py-2.5 rounded-lg text-sm font-semibold cursor-pointer">
|
||||
Start for free
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="animate-fade-in delay-50 grow">
|
||||
<router-view />
|
||||
</main>
|
||||
<!-- Footer -->
|
||||
<footer class="bg-white border-t border-slate-100 pt-16 pb-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-8 mb-12">
|
||||
<div class="col-span-2">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<div class="w-6 h-6 bg-brand-600 rounded flex items-center justify-center text-white">
|
||||
<img class="h-6 w-6" src="/apple-touch-icon.png" alt="Logo" />
|
||||
</div>
|
||||
<span class="font-bold text-lg text-slate-900">EcoStream</span>
|
||||
</div>
|
||||
<p class="text-slate-500 text-sm max-w-xs">Building the video layer of the internet. Designed for
|
||||
developers.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Product</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-500">
|
||||
<li><a href="#" class="hover:text-brand-600">Features</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Pricing</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Showcase</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Company</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-500">
|
||||
<li><a href="#" class="hover:text-brand-600">About</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Blog</a></li>
|
||||
<li><a href="#" class="hover:text-brand-600">Careers</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Legal</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-500">
|
||||
<li><router-link to="/privacy" class="hover:text-brand-600">Privacy</router-link></li>
|
||||
<li><router-link to="/terms" class="hover:text-brand-600">Terms</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-8 border-t border-slate-100 text-center text-sm text-slate-400">
|
||||
© 2026 EcoStream Inc. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<Head>
|
||||
<title>EcoStream - Video infrastructure for modern internet</title>
|
||||
<meta name="description"
|
||||
content="Seamlessly host, encode, and stream video with our developer-first API. Optimized for speed, built for scale." />
|
||||
</Head>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { Head } from '@unhead/vue/components'
|
||||
</script>
|
||||
61
src/routes/home/Privacy.vue
Normal file
61
src/routes/home/Privacy.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;">
|
||||
<div class="grow pt-32 pb-12 px-4">
|
||||
<div class="max-w-4xl mx-auto space-y-10">
|
||||
<div class="space-y-3">
|
||||
<p
|
||||
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase">
|
||||
{{ pageContent.data.pageSubheading }}</p>
|
||||
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1>
|
||||
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p>
|
||||
</div>
|
||||
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
|
||||
<section v-for="(item, index) in pageContent.data.list" :key="index">
|
||||
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2>
|
||||
<p class="leading-relaxed">{{ item.text }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {useHead} from "@unhead/vue";
|
||||
const title = "Privacy Policy - Ecostream";
|
||||
const description = "Read about Ecostream's commitment to protecting your privacy and data security.";
|
||||
const pageContent = {
|
||||
head: {
|
||||
title,
|
||||
meta: [
|
||||
{ name: "description", content: description },
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "twitter:title", content: title },
|
||||
{ property: "twitter:description", content: description },
|
||||
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" }
|
||||
]
|
||||
},
|
||||
data: {
|
||||
pageHeading: "Legal & Privacy Policy",
|
||||
pageSubheading: "Legal & Privacy Policy",
|
||||
description: "Our legal and privacy policy.",
|
||||
list: [{
|
||||
heading: "1. Privacy Policy",
|
||||
text: "At Ecostream, we take your privacy seriously. This policy describes how we collect, use, and protect your personal information. We only collect information that is necessary for the operation of our service, including email addresses for account creation and payment information for subscription processing."
|
||||
},
|
||||
{
|
||||
heading: "2. Data Collection",
|
||||
text: "We collect data such as IP addresses, browser types, and access times to analyze trends and improve our service. Uploaded content is stored securely and is only accessed as required for the delivery of our hosting services."
|
||||
},
|
||||
{
|
||||
heading: "3. Cookie Policy",
|
||||
text: "We use cookies to maintain user sessions and preferences. By using our website, you consent to the use of cookies in accordance with this policy."
|
||||
},
|
||||
{
|
||||
heading: "4. DMCA & Copyright",
|
||||
text: "Ecostream respects the intellectual property rights of others. We respond to notices of alleged copyright infringement in accordance with the Digital Millennium Copyright Act (DMCA). Please report any copyright violations to our support team."
|
||||
}]
|
||||
}
|
||||
}
|
||||
useHead(pageContent.head);
|
||||
</script>
|
||||
67
src/routes/home/Terms.vue
Normal file
67
src/routes/home/Terms.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;">
|
||||
<div class="grow pt-32 pb-12 px-4">
|
||||
<div class="max-w-4xl mx-auto space-y-10">
|
||||
<div class="space-y-3">
|
||||
<p
|
||||
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase">
|
||||
{{ pageContent.data.pageSubheading }}</p>
|
||||
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1>
|
||||
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p>
|
||||
</div>
|
||||
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
|
||||
<section v-for="(item, index) in pageContent.data.list" :key="index">
|
||||
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2>
|
||||
<p class="leading-relaxed">{{ item.text }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {useHead} from "@unhead/vue";
|
||||
const title = "Terms and Conditions - Ecostream";
|
||||
const description = "Read Ecostream's terms and conditions for using our video hosting and streaming services.";
|
||||
const pageContent = {
|
||||
head: {
|
||||
title,
|
||||
meta: [
|
||||
{ name: "description", content: description },
|
||||
{ property: "og:title", content: title },
|
||||
{ property: "og:description", content: description },
|
||||
{ property: "twitter:title", content: title },
|
||||
{ property: "twitter:description", content: description },
|
||||
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" }
|
||||
]
|
||||
},
|
||||
data: {
|
||||
pageHeading: "Terms and Conditions Details",
|
||||
pageSubheading: "Terms and Conditions",
|
||||
description: "Our terms and conditions set forth important guidelines and rules for using Ecostream's services.",
|
||||
list: [
|
||||
{
|
||||
heading: "1. Acceptance of Terms",
|
||||
text: "By accessing and using Ecostream, you accept and agree to be bound by the terms and provision of this agreement."
|
||||
},
|
||||
{
|
||||
heading: "2. Service Usage",
|
||||
text: "You agree to use our service only for lawful purposes. You are prohibited from posting or transmitting any unlawful, threatening, libelous, defamatory, obscene, or profane material. We reserve the right to terminate accounts that violate these terms."
|
||||
},
|
||||
{
|
||||
heading: "3. Content Ownership",
|
||||
text: "You retain all rights and ownership of the content you upload to Ecostream. However, by uploading content, you grant us a license to host, store, and display the content as necessary to provide our services."
|
||||
},
|
||||
{
|
||||
heading: "4. Limitation of Liability",
|
||||
text: "Ecostream shall not be liable for any direct, indirect, incidental, special, or consequential damages resulting from the use or inability to use our service."
|
||||
},
|
||||
{
|
||||
heading: "5. Changes to Terms",
|
||||
text: "We reserve the right to modify these terms at any time. Your continued use of the service after any such changes constitutes your acceptance of the new terms."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
useHead(pageContent.head);
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ReactiveHead, type ResolvableValue } from "@unhead/vue";
|
||||
import { headSymbol } from '@unhead/vue'
|
||||
import { headSymbol } from "@unhead/vue";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRouter,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { inject } from "vue";
|
||||
|
||||
type RouteData = RouteRecordRaw & {
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
||||
@@ -19,15 +20,31 @@ const routes: RouteData[] = [
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: () => import("./home/Home.vue"),
|
||||
beforeEnter: (to, from, next) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.user) {
|
||||
next({ name: "overview" });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
component: () => import("./home/Layout.vue"),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: () => import("./home/Home.vue"),
|
||||
beforeEnter: (to, from, next) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.user) {
|
||||
next({ name: "overview" });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/terms",
|
||||
name: "terms",
|
||||
component: () => import("./home/Terms.vue"),
|
||||
},
|
||||
{
|
||||
path: "/privacy",
|
||||
name: "privacy",
|
||||
component: () => import("./home/Privacy.vue"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
@@ -66,58 +83,74 @@ const routes: RouteData[] = [
|
||||
{
|
||||
path: "",
|
||||
name: "overview",
|
||||
component: () => import("./add/Add.vue"),
|
||||
component: () => import("./overview/Overview.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Overview - Holistream',
|
||||
title: "Overview - Holistream",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "upload",
|
||||
name: "upload",
|
||||
component: () => import("./add/Add.vue"),
|
||||
component: () => import("./upload/Upload.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Upload - Holistream',
|
||||
title: "Upload - Holistream",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "video",
|
||||
name: "video",
|
||||
component: () => import("./add/Add.vue"),
|
||||
component: () => import("./video/Videos.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Videos - Holistream',
|
||||
title: "Videos - Holistream",
|
||||
meta: [
|
||||
{ name: 'description', content: 'Manage your video content.' },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your video content.",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "plans",
|
||||
name: "plans",
|
||||
component: () => import("./add/Add.vue"),
|
||||
path: "payments-and-plans",
|
||||
name: "payments-and-plans",
|
||||
component: () => import("./plans/Plans.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Plans & Billing',
|
||||
title: "Payments & Plans - Holistream",
|
||||
meta: [
|
||||
{ name: 'description', content: 'Manage your plans and billing information.' },
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your plans and billing information.",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "notification",
|
||||
name: "notification",
|
||||
component: () => import("./add/Add.vue"),
|
||||
component: () => import("./notification/Notification.vue"), // TODO: create notification page
|
||||
meta: {
|
||||
head: {
|
||||
title: 'Notification - Holistream',
|
||||
title: "Notification - Holistream",
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
name: "profile",
|
||||
component: () => import("./profile/Profile.vue"), // TODO: create profile page
|
||||
meta: {
|
||||
head: {
|
||||
title: "Profile - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -125,33 +158,39 @@ const routes: RouteData[] = [
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "not-found",
|
||||
component: () => import("./NotFound.vue"),
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
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,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
return { top: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
152
src/routes/notification/Notification.vue
Normal file
152
src/routes/notification/Notification.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import NotificationActions from './components/NotificationActions.vue';
|
||||
import NotificationList from './components/NotificationList.vue';
|
||||
import NotificationTabs from './components/NotificationTabs.vue';
|
||||
|
||||
type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const activeTab = ref('all');
|
||||
|
||||
// Mock notifications data
|
||||
const notifications = ref<Notification[]>([
|
||||
{
|
||||
id: '1',
|
||||
type: 'video',
|
||||
title: 'Video processing complete',
|
||||
message: 'Your video "Summer Vacation 2024" has been successfully processed and is now ready to stream.',
|
||||
time: '2 minutes ago',
|
||||
read: false,
|
||||
actionUrl: '/video',
|
||||
actionLabel: 'View video'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'payment',
|
||||
title: 'Payment successful',
|
||||
message: 'Your subscription to Pro Plan has been renewed successfully. Next billing date: Feb 25, 2026.',
|
||||
time: '1 hour ago',
|
||||
read: false,
|
||||
actionUrl: '/payments-and-plans',
|
||||
actionLabel: 'View receipt'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'warning',
|
||||
title: 'Storage almost full',
|
||||
message: 'You have used 85% of your storage quota. Consider upgrading your plan for more space.',
|
||||
time: '3 hours ago',
|
||||
read: false,
|
||||
actionUrl: '/payments-and-plans',
|
||||
actionLabel: 'Upgrade plan'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'success',
|
||||
title: 'Upload successful',
|
||||
message: 'Your video "Product Demo v2" has been uploaded successfully.',
|
||||
time: '1 day ago',
|
||||
read: true
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'system',
|
||||
title: 'Scheduled maintenance',
|
||||
message: 'We will perform scheduled maintenance on Jan 30, 2026 from 2:00 AM to 4:00 AM UTC.',
|
||||
time: '2 days ago',
|
||||
read: true
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'info',
|
||||
title: 'New feature available',
|
||||
message: 'We just launched video analytics! Track your video performance with detailed insights.',
|
||||
time: '3 days ago',
|
||||
read: true,
|
||||
actionUrl: '/video',
|
||||
actionLabel: 'Try it now'
|
||||
}
|
||||
]);
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'all', label: 'All', icon: 'i-lucide-inbox', count: notifications.value.length },
|
||||
{ key: 'unread', label: 'Unread', icon: 'i-lucide-bell-dot', count: unreadCount.value },
|
||||
{ key: 'video', label: 'Videos', icon: 'i-lucide-video', count: notifications.value.filter(n => n.type === 'video').length },
|
||||
{ key: 'payment', label: 'Payments', icon: 'i-lucide-credit-card', count: notifications.value.filter(n => n.type === 'payment').length }
|
||||
]);
|
||||
|
||||
const filteredNotifications = computed(() => {
|
||||
if (activeTab.value === 'all') return notifications.value;
|
||||
if (activeTab.value === 'unread') return notifications.value.filter(n => !n.read);
|
||||
return notifications.value.filter(n => n.type === activeTab.value);
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
|
||||
|
||||
const handleMarkRead = (id: string) => {
|
||||
const notification = notifications.value.find(n => n.id === id);
|
||||
if (notification) notification.read = true;
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
notifications.value = notifications.value.filter(n => n.id !== id);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = () => {
|
||||
notifications.value.forEach(n => n.read = true);
|
||||
};
|
||||
|
||||
const handleClearAll = () => {
|
||||
notifications.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Notifications"
|
||||
description="Stay updated with your latest activities and alerts."
|
||||
:breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Notifications' }
|
||||
]"
|
||||
/>
|
||||
<div class="w-full max-w-4xl mx-auto mt-6">
|
||||
<div class="notification-container bg-white rounded-2xl border border-gray-200 p-6 shadow-sm">
|
||||
<NotificationActions
|
||||
:loading="loading"
|
||||
:total-count="notifications.length"
|
||||
:unread-count="unreadCount"
|
||||
@mark-all-read="handleMarkAllRead"
|
||||
@clear-all="handleClearAll"
|
||||
/>
|
||||
|
||||
<NotificationTabs
|
||||
:tabs="tabs"
|
||||
:active-tab="activeTab"
|
||||
@update:active-tab="activeTab = $event"
|
||||
/>
|
||||
|
||||
<NotificationList
|
||||
:notifications="filteredNotifications"
|
||||
:loading="loading"
|
||||
@mark-read="handleMarkRead"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
53
src/routes/notification/components/NotificationActions.vue
Normal file
53
src/routes/notification/components/NotificationActions.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
loading?: boolean;
|
||||
totalCount: number;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
markAllRead: [];
|
||||
clearAll: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-header flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="stats flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="i-lucide-bell w-4 h-4 text-gray-400"></span>
|
||||
<span class="text-gray-600">{{ totalCount }} notifications</span>
|
||||
</div>
|
||||
<div v-if="unreadCount > 0" class="flex items-center gap-2 text-sm">
|
||||
<span class="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
|
||||
<span class="text-primary font-medium">{{ unreadCount }} unread</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions flex items-center gap-2">
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
@click="emit('markAllRead')"
|
||||
:disabled="loading"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-600 hover:text-primary
|
||||
hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span class="i-lucide-check-check w-4 h-4"></span>
|
||||
Mark all read
|
||||
</button>
|
||||
<button
|
||||
v-if="totalCount > 0"
|
||||
@click="emit('clearAll')"
|
||||
:disabled="loading"
|
||||
class="px-3 py-2 text-sm font-medium text-gray-600 hover:text-red-600
|
||||
hover:bg-red-50 rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<span class="i-lucide-trash w-4 h-4"></span>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
116
src/routes/notification/components/NotificationItem.vue
Normal file
116
src/routes/notification/components/NotificationItem.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import InfoIcon from '@/components/icons/InfoIcon.vue';
|
||||
import CheckCircleIcon from '@/components/icons/CheckCircleIcon.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
|
||||
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
|
||||
import VideoIcon from '@/components/icons/VideoIcon.vue';
|
||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||
import SettingsIcon from '@/components/icons/SettingsIcon.vue';
|
||||
import ArrowRightIcon from '@/components/icons/ArrowRightIcon.vue';
|
||||
import CheckMarkIcon from '@/components/icons/CheckMarkIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
|
||||
interface Props {
|
||||
notification: {
|
||||
id: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
||||
title: string;
|
||||
message: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
actionLabel?: string;
|
||||
};
|
||||
isDrawer?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
markRead: [id: string];
|
||||
delete: [id: string];
|
||||
}>();
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const icons: Record<string, any> = {
|
||||
info: InfoIcon,
|
||||
success: CheckCircleIcon,
|
||||
warning: AlertTriangleIcon,
|
||||
error: XCircleIcon,
|
||||
video: VideoIcon,
|
||||
payment: CreditCardIcon,
|
||||
system: SettingsIcon
|
||||
};
|
||||
return icons[props.notification.type] || InfoIcon;
|
||||
});
|
||||
|
||||
const iconColorClass = computed(() => {
|
||||
const colors: Record<string, string> = {
|
||||
info: 'text-blue-500',
|
||||
success: 'text-green-500',
|
||||
warning: 'text-amber-500',
|
||||
error: 'text-red-500',
|
||||
video: 'text-purple-500',
|
||||
payment: 'text-emerald-500',
|
||||
system: 'text-gray-500'
|
||||
};
|
||||
return colors[props.notification.type] || 'text-blue-500';
|
||||
});
|
||||
|
||||
const bgClass = computed(() => {
|
||||
return props.notification.read
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-blue-50/50 hover:bg-blue-50';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'rounded-xl p-4 border border-gray-200/80 transition-all duration-200',
|
||||
'flex items-start gap-4 group cursor-pointer relative',
|
||||
bgClass
|
||||
]" @click="emit('markRead', notification.id)">
|
||||
<!-- Icon -->
|
||||
<div v-if="!isDrawer" class="flex-shrink-0 w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<component :is="iconComponent" :class="[iconColorClass, 'w-5 h-5']" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h4 :class="['font-semibold text-gray-900', !notification.read && 'text-primary-700']">
|
||||
{{ notification.title }}
|
||||
</h4>
|
||||
<span class="text-xs text-gray-400 whitespace-nowrap">{{ notification.time }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mt-1 line-clamp-2">{{ notification.message }}</p>
|
||||
|
||||
<!-- Action Button -->
|
||||
<router-link v-if="notification.actionUrl" :to="notification.actionUrl"
|
||||
class="inline-flex items-center gap-1 text-sm text-primary font-medium mt-2 hover:underline">
|
||||
{{ notification.actionLabel || 'View Details' }}
|
||||
<ArrowRightIcon class="w-4 h-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="!isDrawer"
|
||||
class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
|
||||
<button v-if="!notification.read" @click.stop="emit('markRead', notification.id)"
|
||||
class="p-2 rounded-lg hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="Mark as read">
|
||||
<CheckMarkIcon class="w-4 h-4" />
|
||||
</button>
|
||||
<button @click.stop="emit('delete', notification.id)"
|
||||
class="p-2 rounded-lg hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors"
|
||||
title="Delete">
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Unread indicator -->
|
||||
<div v-if="!notification.read"
|
||||
class="absolute left-2 top-1/10 -translate-y-1/2 w-2 h-2 rounded-full bg-primary">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
src/routes/notification/components/NotificationList.vue
Normal file
69
src/routes/notification/components/NotificationList.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import NotificationItem from './NotificationItem.vue';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
||||
title: string;
|
||||
message: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
actionUrl?: string;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
notifications: Notification[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
markRead: [id: string];
|
||||
delete: [id: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-list space-y-3">
|
||||
<!-- Loading skeleton -->
|
||||
<template v-if="loading">
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
class="p-4 rounded-xl border border-gray-200 animate-pulse"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-gray-200"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Notification items -->
|
||||
<template v-else-if="notifications.length > 0">
|
||||
<NotificationItem
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
:notification="notification"
|
||||
@mark-read="emit('markRead', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else
|
||||
class="py-16 text-center"
|
||||
>
|
||||
<div class="w-20 h-20 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<span class="i-lucide-bell-off w-10 h-10 text-gray-400"></span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">No notifications</h3>
|
||||
<p class="text-gray-500">You're all caught up! Check back later.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
49
src/routes/notification/components/NotificationTabs.vue
Normal file
49
src/routes/notification/components/NotificationTabs.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
interface Tab {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
'update:activeTab': [key: string];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notification-tabs flex items-center gap-1 p-1 bg-gray-100 rounded-xl mb-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="emit('update:activeTab', tab.key)"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-200',
|
||||
'flex items-center justify-center gap-2',
|
||||
activeTab === tab.key
|
||||
? 'bg-white text-primary shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'
|
||||
]"
|
||||
>
|
||||
<span :class="[tab.icon, 'w-4 h-4']"></span>
|
||||
{{ tab.label }}
|
||||
<span
|
||||
v-if="tab.count && tab.count > 0"
|
||||
:class="[
|
||||
'px-1.5 py-0.5 text-xs rounded-full min-w-[20px]',
|
||||
activeTab === tab.key
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
]"
|
||||
>
|
||||
{{ tab.count > 99 ? '99+' : tab.count }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
76
src/routes/overview/Overview.vue
Normal file
76
src/routes/overview/Overview.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="tsx">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import NameGradient from './components/NameGradient.vue';
|
||||
import QuickActions from './components/QuickActions.vue';
|
||||
import RecentVideos from './components/RecentVideos.vue';
|
||||
import StatsOverview from './components/StatsOverview.vue';
|
||||
|
||||
const loading = ref(true);
|
||||
const recentVideos = ref<ModelVideo[]>([]);
|
||||
|
||||
// Mock stats data (in real app, fetch from API)
|
||||
const stats = ref({
|
||||
totalVideos: 0,
|
||||
totalViews: 0,
|
||||
storageUsed: 0,
|
||||
storageLimit: 10737418240, // 10GB in bytes
|
||||
uploadsThisMonth: 0
|
||||
});
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Fetch recent videos
|
||||
const response = await client.videos.videosList({ page: 1, limit: 5 });
|
||||
const body = response.data as any;
|
||||
|
||||
if (body.data && Array.isArray(body.data)) {
|
||||
recentVideos.value = body.data;
|
||||
stats.value.totalVideos = body.data.length;
|
||||
} else if (Array.isArray(body)) {
|
||||
recentVideos.value = body;
|
||||
stats.value.totalVideos = body.length;
|
||||
}
|
||||
|
||||
// Calculate mock stats
|
||||
stats.value.totalViews = recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0);
|
||||
stats.value.storageUsed = recentVideos.value.reduce((sum, v) => sum + (v.size || 0), 0);
|
||||
stats.value.uploadsThisMonth = recentVideos.value.filter(v => {
|
||||
const uploadDate = new Date(v.created_at || '');
|
||||
const now = new Date();
|
||||
return uploadDate.getMonth() === now.getMonth() && uploadDate.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchDashboardData();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-overview">
|
||||
<PageHeader :title="NameGradient" description="Welcome back, Here's what's happening with your videos." :breadcrumbs="[
|
||||
{ label: 'Dashboard' }
|
||||
]" />
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<StatsOverview :loading="loading" :stats="stats" />
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<QuickActions :loading="loading" />
|
||||
|
||||
<!-- Recent Videos -->
|
||||
<RecentVideos :loading="loading" :videos="recentVideos" />
|
||||
|
||||
<!-- Storage Usage -->
|
||||
<!-- <StorageUsage :loading="loading" :stats="stats" /> -->
|
||||
</div>
|
||||
</template>
|
||||
10
src/routes/overview/components/NameGradient.vue
Normal file
10
src/routes/overview/components/NameGradient.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="text-3xl font-bold text-gray-900 mb-1">
|
||||
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">Hello, {{ auth.user?.username }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const auth = useAuthStore()
|
||||
</script>
|
||||
85
src/routes/overview/components/QuickActions.vue
Normal file
85
src/routes/overview/components/QuickActions.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import Chart from '@/components/icons/Chart.vue';
|
||||
import Credit from '@/components/icons/Credit.vue';
|
||||
import Upload from '@/components/icons/Upload.vue';
|
||||
import Video from '@/components/icons/Video.vue';
|
||||
import { Skeleton } from '@/components/ui/form';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Referral from './Referral.vue';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
title: 'Upload Video',
|
||||
description: 'Upload a new video to your library',
|
||||
icon: Upload,
|
||||
onClick: () => router.push('/upload')
|
||||
},
|
||||
{
|
||||
title: 'Video Library',
|
||||
description: 'Browse all your videos',
|
||||
icon: Video,
|
||||
onClick: () => router.push('/video')
|
||||
},
|
||||
{
|
||||
title: 'Analytics',
|
||||
description: 'Track performance & insights',
|
||||
icon: Chart,
|
||||
onClick: () => { }
|
||||
},
|
||||
{
|
||||
title: 'Manage Plan',
|
||||
description: 'Upgrade or change your plan',
|
||||
icon: Credit,
|
||||
onClick: () => router.push('/payments-and-plans')
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="mb-8">
|
||||
<Skeleton width="10rem" height="1.5rem" class="mb-4" />
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton width="3rem" height="3rem" borderRadius="9999px" class="mb-4" />
|
||||
<Skeleton width="8rem" height="1.25rem" class="mb-2" />
|
||||
<Skeleton width="100%" height="1rem" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton width="10rem" height="2rem" />
|
||||
<Skeleton width="100%" height="1.25rem" class="my-4" />
|
||||
<Skeleton width="100%" height="1rem" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button v-for="action in quickActions" :key="action.title" @click="action.onClick" :class="[
|
||||
'p-6 rounded-xl text-left transition-all duration-200 flex flex-col bg-surface',
|
||||
'border border-gray-300 hover:border-primary hover:shadow-lg',
|
||||
'group press-animated',
|
||||
]">
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-muted group-hover:bg-primary/10">
|
||||
<component filled :is="action.icon" class="w-6 h-6" />
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ action.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
<Referral />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
134
src/routes/overview/components/RecentVideos.vue
Normal file
134
src/routes/overview/components/RecentVideos.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { ModelVideo } from '@/api/client';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import { Skeleton } from '@/components/ui/form';
|
||||
import { formatDate, formatDuration } from '@/lib/utils';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
videos: ModelVideo[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const getStatusClass = (status?: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-8">
|
||||
<div v-if="loading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Skeleton width="8rem" height="1.5rem"></Skeleton>
|
||||
<Skeleton width="5rem" height="1rem"></Skeleton>
|
||||
</div>
|
||||
<div 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">
|
||||
<Skeleton width="4rem" height="2.5rem" class="rounded"></Skeleton>
|
||||
<div class="flex-1 space-y-2">
|
||||
<Skeleton width="30%" height="1rem"></Skeleton>
|
||||
<Skeleton width="20%" height="0.8rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">Recent Videos</h2>
|
||||
<router-link to="/video"
|
||||
class="text-sm text-primary hover:underline font-medium flex items-center gap-1">
|
||||
View all
|
||||
<span class="i-heroicons-arrow-right w-4 h-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="videos.length === 0" title="No videos found"
|
||||
description="You haven't uploaded any videos yet. Start by uploading your first video!"
|
||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
|
||||
:onAction="() => router.push('/upload')" />
|
||||
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Video</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Duration</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Upload Date</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="video in videos" :key="video.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 h-12 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
||||
<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">
|
||||
<span class="i-heroicons-film text-gray-400 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">
|
||||
{{ video.description || 'No description' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]">
|
||||
{{ video.status || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDate(video.created_at) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
|
||||
<span class="i-heroicons-pencil w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share">
|
||||
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-red-100 rounded transition-colors" title="Delete">
|
||||
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
46
src/routes/overview/components/Referral.vue
Normal file
46
src/routes/overview/components/Referral.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground bg-surface">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<h3 class="text-lg font-semibold leading-none tracking-tight">Referral Link</h3>
|
||||
</div>
|
||||
<div class="p-6 pt-0 space-y-4">
|
||||
<p class="text-sm text-gray-600 font-medium">Share your referral link and earn commissions from
|
||||
referred users!</p>
|
||||
<div class="flex gap-2">
|
||||
<InputText class="w-full" readonly type="text" :value="url" @click="copyToClipboard" />
|
||||
<button class="btn btn-primary" @click="copyToClipboard" :disabled="isCopied">
|
||||
<svg v-if="!isCopied" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true">
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" class="lucide lucide-check" aria-hidden="true">
|
||||
<path d="M22 11.02V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<path d="M22 4L12 14.01l-3-3"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { ref } from 'vue';
|
||||
const auth = useAuthStore()
|
||||
const isCopied = ref(false)
|
||||
const url = location.origin + '/ref/' + auth.user?.username
|
||||
const copyToClipboard = ($event: MouseEvent) => {
|
||||
// ($event.target as HTMLInputElement)?.select
|
||||
if ($event.target instanceof HTMLInputElement) {
|
||||
$event.target.select()
|
||||
}
|
||||
navigator.clipboard.writeText(url)
|
||||
isCopied.value = true
|
||||
setTimeout(() => {
|
||||
isCopied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
45
src/routes/overview/components/StatsOverview.vue
Normal file
45
src/routes/overview/components/StatsOverview.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
||||
import { Skeleton } from '@/components/ui/form';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
stats: {
|
||||
totalVideos: number;
|
||||
totalViews: number;
|
||||
storageUsed: number;
|
||||
storageLimit: number;
|
||||
uploadsThisMonth: number;
|
||||
};
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div v-for="i in 4" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="space-y-2">
|
||||
<Skeleton width="5rem" height="1rem" class="mb-2" />
|
||||
<Skeleton width="8rem" height="2rem" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton width="4rem" height="1rem" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard title="Total Videos" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
|
||||
|
||||
<StatsCard title="Total Views" :value="stats.totalViews.toLocaleString()"
|
||||
:trend="{ value: 8, isPositive: true }" />
|
||||
|
||||
<StatsCard title="Storage Used"
|
||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
|
||||
|
||||
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth" color="success"
|
||||
:trend="{ value: 25, isPositive: true }" />
|
||||
</div>
|
||||
</template>
|
||||
78
src/routes/overview/components/StorageUsage.vue
Normal file
78
src/routes/overview/components/StorageUsage.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
stats: {
|
||||
totalVideos: number;
|
||||
storageUsed: number;
|
||||
storageLimit: number;
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const storagePercentage = computed(() => {
|
||||
return Math.round((props.stats.storageUsed / props.stats.storageLimit) * 100);
|
||||
});
|
||||
|
||||
const storageBreakdown = computed(() => {
|
||||
const videoSize = props.stats.storageUsed;
|
||||
const thumbSize = props.stats.totalVideos * 300 * 1024; // ~300KB per thumbnail
|
||||
const otherSize = props.stats.totalVideos * 100 * 1024; // ~100KB other files
|
||||
const total = videoSize + thumbSize + otherSize;
|
||||
|
||||
return [
|
||||
{ label: 'Videos', size: videoSize, percentage: (videoSize / (total || 1)) * 100, color: 'bg-primary' },
|
||||
{ label: 'Thumbnails & Assets', size: thumbSize, percentage: (thumbSize / (total || 1)) * 100, color: 'bg-blue-500' },
|
||||
{ label: 'Other Files', size: otherSize, percentage: (otherSize / (total || 1)) * 100, color: 'bg-gray-400' },
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading" class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Storage Usage</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
{{ formatBytes(stats.storageUsed) }} of {{ formatBytes(stats.storageLimit) }} used
|
||||
</span>
|
||||
<span class="text-sm font-medium" :class="storagePercentage > 80 ? 'text-danger' : 'text-gray-700'">
|
||||
{{ storagePercentage }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div class="h-full transition-all duration-500 rounded-full"
|
||||
:class="storagePercentage > 80 ? 'bg-danger' : 'bg-primary'"
|
||||
:style="{ width: `${storagePercentage}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div v-for="item in storageBreakdown" :key="item.label" class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div :class="['w-3 h-3 rounded-sm', item.color]" />
|
||||
<span class="text-gray-700">{{ item.label }}</span>
|
||||
</div>
|
||||
<span class="text-gray-500">{{ formatBytes(item.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="storagePercentage > 80" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex gap-2">
|
||||
<span class="i-heroicons-exclamation-triangle w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800">Storage running low</p>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
Consider upgrading your plan to get more storage.
|
||||
<router-link to="/plans" class="underline font-medium">View plans</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
16
src/routes/overview/components/WelcomeBanner.vue
Normal file
16
src/routes/overview/components/WelcomeBanner.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-gradient-to-r to-success/20 p-4 sm:p-6 md:p-8 rounded-xl border-2 border-success/30 mb-8">
|
||||
<h1 class="text-2xl sm:text-3xl md:text-4xl font-extrabold text-foreground mb-2">Welcome back, {{
|
||||
auth.user?.username }}! 👋
|
||||
</h1>
|
||||
<p class="text-sm sm:text-base text-gray-600 font-medium">Here's what's happening with your content
|
||||
today.</p>
|
||||
</div>
|
||||
</template>
|
||||
186
src/routes/plans/Plans.vue
Normal file
186
src/routes/plans/Plans.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import CurrentPlanCard from './components/CurrentPlanCard.vue';
|
||||
import EditPlanDialog from './components/EditPlanDialog.vue';
|
||||
import ManageSubscriptionDialog from './components/ManageSubscriptionDialog.vue';
|
||||
import PlanList from './components/PlanList.vue';
|
||||
import PlanPaymentHistory from './components/PlanPaymentHistory.vue';
|
||||
import UsageStatsCard from './components/UsageStatsCard.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const subscribing = ref<string | null>(null);
|
||||
const showManageDialog = ref(false);
|
||||
const cancelling = ref(false);
|
||||
const isLoading = ref(true);
|
||||
const plansData = ref<any>(null);
|
||||
|
||||
// Mock Payment History Data
|
||||
const paymentHistory = ref([
|
||||
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
|
||||
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
|
||||
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
|
||||
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
|
||||
]);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
plansData.value = await client.plans.plansList();
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch plans', e);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchPlans();
|
||||
});
|
||||
|
||||
// Computed Usage (Mock if not in store)
|
||||
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
|
||||
const storageLimit = computed(() => 10737418240);
|
||||
const uploadsUsed = ref(12);
|
||||
const uploadsLimit = ref(50);
|
||||
|
||||
const currentPlanId = computed(() => {
|
||||
if (auth.user?.plan_id) return auth.user.plan_id;
|
||||
if (Array.isArray(plansData.value?.data?.data?.plans) && plansData.value?.data?.data?.plans.length > 0) return plansData.value.data.data.plans[0].id;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const currentPlan = computed(() => {
|
||||
if (!Array.isArray(plansData.value?.data?.data?.plans)) return undefined;
|
||||
return plansData.value.data.data.plans.find((p: ModelPlan) => p.id === currentPlanId.value);
|
||||
});
|
||||
|
||||
const showEditDialog = ref(false);
|
||||
const editingPlan = ref<ModelPlan>({});
|
||||
const isSaving = ref(false);
|
||||
|
||||
const openEditPlan = (plan: ModelPlan) => {
|
||||
editingPlan.value = { ...plan };
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const savePlan = async (updatedPlan: ModelPlan) => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
if (!updatedPlan.id) return;
|
||||
|
||||
await client.request({
|
||||
path: `/plans/${updatedPlan.id}`,
|
||||
method: 'PUT',
|
||||
body: updatedPlan
|
||||
});
|
||||
|
||||
await fetchPlans();
|
||||
showEditDialog.value = false;
|
||||
alert('Plan updated successfully');
|
||||
} catch (e: any) {
|
||||
console.error('Failed to update plan', e);
|
||||
showEditDialog.value = false;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const subscribe = async (plan: ModelPlan) => {
|
||||
if (!plan.id) return;
|
||||
subscribing.value = plan.id;
|
||||
try {
|
||||
await client.payments.paymentsCreate({
|
||||
amount: plan.price || 0,
|
||||
plan_id: plan.id
|
||||
});
|
||||
alert(`Successfully subscribed to ${plan.name}`);
|
||||
|
||||
paymentHistory.value.unshift({
|
||||
id: `inv_${Date.now()}`,
|
||||
date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
|
||||
amount: plan.price || 0,
|
||||
plan: plan.name || 'Unknown',
|
||||
status: 'success',
|
||||
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert('Failed to subscribe: ' + (err.message || 'Unknown error'));
|
||||
} finally {
|
||||
subscribing.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelSubscription = async () => {
|
||||
cancelling.value = true;
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
alert('Subscription has been canceled.');
|
||||
showManageDialog.value = false;
|
||||
} catch (e) {
|
||||
alert('Failed to cancel subscription.');
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plans-page">
|
||||
<PageHeader
|
||||
title="Subscription"
|
||||
description="Manage your workspace plan and usage"
|
||||
:breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Subscription' }
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="content max-w-7xl mx-auto space-y-12 pb-12">
|
||||
|
||||
<!-- Hero Section: Current Plan & Usage -->
|
||||
<div v-if="!isLoading" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<CurrentPlanCard
|
||||
:current-plan="currentPlan"
|
||||
@manage="showManageDialog = true"
|
||||
/>
|
||||
|
||||
<UsageStatsCard
|
||||
:storage-used="storageUsed"
|
||||
:storage-limit="storageLimit"
|
||||
:uploads-used="uploadsUsed"
|
||||
:uploads-limit="uploadsLimit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PlanList
|
||||
:plans="plansData?.data?.data?.plans || []"
|
||||
:is-loading="!!isLoading"
|
||||
:current-plan-id="currentPlanId"
|
||||
:subscribing-plan-id="subscribing"
|
||||
:is-admin="auth.user?.role === 'admin'"
|
||||
@subscribe="subscribe"
|
||||
@edit="openEditPlan"
|
||||
/>
|
||||
|
||||
<PlanPaymentHistory :history="paymentHistory" />
|
||||
|
||||
<ManageSubscriptionDialog
|
||||
v-model:visible="showManageDialog"
|
||||
:current-plan="currentPlan"
|
||||
:cancelling="cancelling"
|
||||
@cancel-subscription="cancelSubscription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditPlanDialog
|
||||
v-model:visible="showEditDialog"
|
||||
:plan="editingPlan"
|
||||
:loading="isSaving"
|
||||
@save="savePlan"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
38
src/routes/plans/components/CurrentPlanCard.vue
Normal file
38
src/routes/plans/components/CurrentPlanCard.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { type ModelPlan } from '@/api/client';
|
||||
import { Button, Tag } from '@/components/ui/form';
|
||||
|
||||
defineProps<{
|
||||
currentPlan?: ModelPlan;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: 'manage'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lg:col-span-2 relative overflow-hidden rounded-2xl bg-gradient-to-br from-gray-900 to-gray-800 text-white p-8">
|
||||
<!-- Background decorations -->
|
||||
<div class="absolute top-0 right-0 -mt-16 -mr-16 w-64 h-64 bg-primary-500 rounded-full blur-3xl opacity-20"></div>
|
||||
<div class="absolute bottom-0 left-0 -mb-16 -ml-16 w-64 h-64 bg-purple-500 rounded-full blur-3xl opacity-20"></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col h-full justify-between">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-1">Current Plan</h2>
|
||||
<h3 class="text-4xl font-bold text-white mb-2">{{ currentPlan?.name || 'Standard Plan' }}</h3>
|
||||
<Tag value="Active" severity="success" />
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-3xl font-bold text-white">${{ currentPlan?.price || 0 }}<span class="text-lg text-gray-400 font-normal">/mo</span></div>
|
||||
<p class="text-gray-400 text-sm mt-1">Next billing on Feb 24, 2026</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-gray-700/50 flex gap-4">
|
||||
<Button label="Manage Subscription" variant="secondary" @click="$emit('manage')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
132
src/routes/plans/components/EditPlanDialog.vue
Normal file
132
src/routes/plans/components/EditPlanDialog.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { type ModelPlan } from '@/api/client';
|
||||
import { Button, Dialog } from '@/components/ui/form';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
plan: ModelPlan;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'save', plan: ModelPlan): void;
|
||||
}>();
|
||||
|
||||
// Create a local copy to edit
|
||||
const localPlan = ref<ModelPlan>({});
|
||||
|
||||
// Sync when dialog opens or plan changes
|
||||
watch(() => props.plan, (newPlan) => {
|
||||
localPlan.value = { ...newPlan };
|
||||
}, { immediate: true });
|
||||
|
||||
const onSave = () => {
|
||||
emit('save', localPlan.value);
|
||||
};
|
||||
|
||||
const visibleModel = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:visible="visibleModel" header="Edit Plan" :style="{ width: '40rem' }">
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-name" class="text-sm font-medium text-gray-700">Name</label>
|
||||
<input
|
||||
id="plan-name"
|
||||
v-model="localPlan.name"
|
||||
type="text"
|
||||
placeholder="Plan Name"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-price" class="text-sm font-medium text-gray-700">Price ($)</label>
|
||||
<input
|
||||
id="plan-price"
|
||||
v-model="localPlan.price"
|
||||
type="number"
|
||||
placeholder="Price"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-cycle" class="text-sm font-medium text-gray-700">Billing Cycle</label>
|
||||
<input
|
||||
id="plan-cycle"
|
||||
v-model="localPlan.cycle"
|
||||
type="text"
|
||||
placeholder="e.g. month, year"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-desc" class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea
|
||||
id="plan-desc"
|
||||
v-model="localPlan.description"
|
||||
rows="2"
|
||||
placeholder="Description"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-storage" class="text-sm font-medium text-gray-700">Storage Limit (bytes)</label>
|
||||
<input
|
||||
id="plan-storage"
|
||||
v-model="localPlan.storage_limit"
|
||||
type="number"
|
||||
placeholder="Storage limit"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-uploads" class="text-sm font-medium text-gray-700">Upload Limit (per day)</label>
|
||||
<input
|
||||
id="plan-uploads"
|
||||
v-model="localPlan.upload_limit"
|
||||
type="number"
|
||||
placeholder="Upload limit"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-duration" class="text-sm font-medium text-gray-700">Duration Limit (sec)</label>
|
||||
<input
|
||||
id="plan-duration"
|
||||
v-model="localPlan.duration_limit"
|
||||
type="number"
|
||||
placeholder="Duration limit"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="plan-active"
|
||||
v-model="localPlan.is_active"
|
||||
class="w-4 h-4 rounded border-gray-300"
|
||||
/>
|
||||
<label for="plan-active" class="text-sm font-medium text-gray-700">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="secondary" label="Cancel" @click="visibleModel = false" />
|
||||
<Button label="Save Changes" @click="onSave" :loading="loading" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
54
src/routes/plans/components/ManageSubscriptionDialog.vue
Normal file
54
src/routes/plans/components/ManageSubscriptionDialog.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { type ModelPlan } from '@/api/client';
|
||||
import { Button, Dialog } from '@/components/ui/form';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
currentPlan?: ModelPlan;
|
||||
cancelling?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'cancel-subscription'): void;
|
||||
}>();
|
||||
|
||||
const visibleModel = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:visible="visibleModel" header="Manage Subscription" :style="{ width: '30rem' }">
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-600 mb-4">You are currently subscribed to <span class="font-bold text-gray-900">{{ currentPlan?.name }}</span>.</p>
|
||||
<div class="bg-gray-50 p-4 rounded-lg space-y-2 border border-gray-200">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Status</span>
|
||||
<span class="text-sm font-medium text-green-600">Active</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Renewal Date</span>
|
||||
<span class="text-sm font-medium text-gray-900">Feb 24, 2026</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Amount</span>
|
||||
<span class="text-sm font-medium text-gray-900">${{ currentPlan?.price || 0 }}/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
Canceling your subscription will downgrade you to the Free plan at the end of your current billing period.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="secondary" label="Close" @click="visibleModel = false" />
|
||||
<Button
|
||||
label="Cancel Subscription"
|
||||
@click="emit('cancel-subscription')"
|
||||
:disabled="cancelling"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
101
src/routes/plans/components/PlanList.vue
Normal file
101
src/routes/plans/components/PlanList.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { type ModelPlan } from '@/api/client';
|
||||
import { Button, Skeleton } from '@/components/ui/form';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
|
||||
defineProps<{
|
||||
plans: ModelPlan[];
|
||||
isLoading: boolean;
|
||||
currentPlanId?: string;
|
||||
subscribingPlanId?: string | null;
|
||||
isAdmin?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'subscribe', plan: ModelPlan): void;
|
||||
(e: 'edit', plan: ModelPlan): void;
|
||||
}>();
|
||||
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '0 mins';
|
||||
return `${Math.floor(seconds / 60)} mins`;
|
||||
};
|
||||
|
||||
const isPopular = (plan: ModelPlan) => {
|
||||
return plan.name?.toLowerCase().includes('pro') || plan.name?.toLowerCase().includes('premium');
|
||||
};
|
||||
|
||||
const isCurrentComp = (plan: ModelPlan, currentId?: string) => {
|
||||
return plan.id === currentId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Upgrade your workspace</h2>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div v-for="i in 3" :key="i" class="h-full">
|
||||
<Skeleton height="300px" borderRadius="16px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-8 items-start">
|
||||
<div v-for="plan in plans" :key="plan.id" class="relative group h-full">
|
||||
<div v-if="isPopular(plan) && !isCurrentComp(plan, currentPlanId)" class="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-white text-xs font-bold px-3 py-1 rounded-full z-10 shadow-md uppercase tracking-wide">
|
||||
Recommended
|
||||
</div>
|
||||
|
||||
<!-- Admin Edit Button -->
|
||||
<Button
|
||||
v-if="isAdmin"
|
||||
class="absolute top-2 right-2 z-20"
|
||||
variant="secondary"
|
||||
@click.stop="emit('edit', plan)"
|
||||
/>
|
||||
|
||||
<div :class="[
|
||||
'relative bg-white rounded-2xl p-6 h-full border transition-all duration-200 flex flex-col',
|
||||
isCurrentComp(plan, currentPlanId) ? 'border-primary ring-1 ring-primary/50 bg-primary-50/10' : 'border-gray-200 hover:border-gray-300 hover:shadow-lg',
|
||||
isPopular(plan) && !isCurrentComp(plan, currentPlanId) ? 'shadow-md border-primary/20' : ''
|
||||
]">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ plan.name }}</h3>
|
||||
<p class="text-gray-500 text-sm min-h-[2.5rem] mt-2">{{ plan.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="text-4xl font-bold text-gray-900">${{ plan.price }}</span>
|
||||
<span class="text-gray-500 text-sm">/{{ plan.cycle }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 mb-8 flex-grow">
|
||||
<li class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
|
||||
{{ formatBytes(plan.storage_limit || 0) }} Storage
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
|
||||
{{ formatDuration(plan.duration_limit) }} Max Duration
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
|
||||
{{ plan.upload_limit }} Uploads / day
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
:label="isCurrentComp(plan, currentPlanId) ? 'Current Plan' : (subscribingPlanId === plan.id ? 'Processing...' : 'Upgrade')"
|
||||
class="w-full"
|
||||
:variant="isCurrentComp(plan, currentPlanId) ? 'outlined' : 'primary'"
|
||||
:disabled="!!subscribingPlanId || isCurrentComp(plan, currentPlanId)"
|
||||
@click="emit('subscribe', plan)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user