feat(settings): add Billing, Danger Zone, Domains DNS, Notification, Player, and Security settings pages

- Implemented Billing page with wallet balance, current plan, usage stats, available plans, and payment history.
- Created Danger Zone page for account deletion and data clearing actions with confirmation prompts.
- Developed Domains DNS page for managing whitelisted domains for iframe embedding, including add and remove functionality.
- Added Notification Settings page to configure email, push, marketing, and Telegram notifications.
- Introduced Player Settings page to customize video player behavior such as autoplay, loop, and controls visibility.
- Established Security and Connected Accounts page for managing user profile, two-factor authentication, and connected accounts.
This commit is contained in:
2026-03-01 22:49:30 +07:00
parent c6924afe5b
commit cd9aab8979
65 changed files with 3150 additions and 1133 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(bun run build)"
]
}
}

View File

@@ -40,6 +40,8 @@ bun run cf-typegen
bun run tail
```
**Note**: The project uses Bun as the package manager. If using npm/yarn, replace `bun` with `npm run` or `yarn`.
## Architecture
### SSR Architecture
@@ -70,9 +72,10 @@ Uses **Pinia Colada** for server state with SSR hydration:
The API client (`src/api/client.ts`) is auto-generated from OpenAPI spec:
- Uses `customFetch` adapter that differs between client/server
- Server adapter (`httpClientAdapter.server.ts`): Forwards cookies, merges headers, calls `api.pipic.fun`
- Client adapter (`httpClientAdapter.client.ts`): Standard fetch with credentials
- API proxy route: `/r/*` paths proxy to `https://api.pipic.fun`
- Server adapter (`httpClientAdapter.server.ts`): Forwards cookies via `hono/context-storage`, merges headers, calls `https://api.pipic.fun`
- Client adapter (`httpClientAdapter.client.ts`): Standard fetch with `credentials: "include"`
- API proxy route: `/r/*` paths proxy to `https://api.pipic.fun` via `apiProxyMiddleware`
- Base API URL constant: `baseAPIURL = "https://api.pipic.fun"`
### Routing Structure
@@ -119,6 +122,10 @@ Upload queue (`src/composables/useUploadQueue.ts`):
- Presigned POST URLs fetched from API
- Parallel chunk upload for large files
- Progress tracking with speed calculation
- **Chunk configuration**: 90MB chunks, max 3 parallel uploads, max 3 retries
- **Upload limits**: Max 5 items in queue
- Uses `tmpfiles.org` API for chunk uploads, `/merge` endpoint for finalizing
- Cancel support via XHR abort tracking
### Type Safety
@@ -148,6 +155,18 @@ Cloudflare Worker bindings (configured in `wrangler.jsonc`):
| Wrangler config | `wrangler.jsonc` |
| Vite config | `vite.config.ts` |
## Server Structure
Middleware and routes are organized in `src/server/`:
**Middlewares** (`src/server/middlewares/`):
- `setup.ts` - Global middleware: `contextStorage`, CORS, mobile detection via `is-mobile`
- `apiProxy.ts` - Proxies `/r/*` requests to external API
**Routes** (`src/server/routes/`):
- `ssr.ts` - Handles SSR rendering and state serialization
- `display.ts`, `merge.ts`, `manifest.ts`, `wellKnown.ts` - API endpoints
## Development Notes
- Always use `customFetch` from `@httpClientAdapter` for API calls, never raw fetch
@@ -155,4 +174,25 @@ Cloudflare Worker bindings (configured in `wrangler.jsonc`):
- MQTT client in `src/lib/liteMqtt.ts` (using `TinyMqttClient`) handles real-time notifications
- Icons are custom Vue components in `src/components/icons/`
- Upload indicator is a global component showing queue status
- Root component uses error boundary wrapper: `withErrorBoundary(RouterView)` in `src/main.ts`
- **Testing & Linting**: There are currently no automated test suites (like Vitest) or linting tools (like ESLint/Prettier) configured.
## Code Organization
### Component Structure
- Keep view components small and focused - extract logical sections into child components
- Page views should compose child components, not contain all logic inline
- Example: `src/routes/settings/Settings.vue` uses child components in `src/routes/settings/components/`
- Components that exceed ~200 lines should be considered for refactoring
- Use `components/` subfolder pattern for page-specific components: `src/routes/{feature}/components/`
### Icons
- **Use custom SVG icon components** from `src/components/icons/` for UI icons (e.g., `Home`, `Video`, `Bell`, `SettingsIcon`)
- Custom icons are Vue components with `filled` prop for active/filled state
- PrimeIcons (`pi pi-*` class) should **only** be used for:
- Button icons in PrimeVue components (e.g., `icon="pi pi-check"`)
- Dialog/action icons where no custom SVG exists
- **Do NOT use** `<i class="pi pi-*">` for navigation icons, action buttons, or UI elements that have custom SVG equivalents
- When adding new icons, create SVG components in `src/components/icons/` following the existing pattern (support `filled` prop)

68
components.d.ts vendored
View File

@@ -12,11 +12,15 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
Add: typeof import('./src/components/icons/Add.vue')['default']
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.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']
Bell: typeof import('./src/components/icons/Bell.vue')['default']
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
Button: typeof import('primevue/button')['default']
Chart: typeof import('./src/components/icons/Chart.vue')['default']
Checkbox: typeof import('primevue/checkbox')['default']
@@ -24,56 +28,82 @@ declare module 'vue' {
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']
CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['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('primevue/dialog')['default']
DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
FloatLabel: typeof import('primevue/floatlabel')['default']
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
Globe: typeof import('./src/components/icons/Globe.vue')['default']
GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
Home: typeof import('./src/components/icons/Home.vue')['default']
IconField: typeof import('primevue/iconfield')['default']
ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
InputIcon: typeof import('primevue/inputicon')['default']
InputNumber: typeof import('primevue/inputnumber')['default']
InputText: typeof import('primevue/inputtext')['default']
Layout: typeof import('./src/components/icons/Layout.vue')['default']
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
Message: typeof import('primevue/message')['default']
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
Paginator: typeof import('primevue/paginator')['default']
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
Password: typeof import('primevue/password')['default']
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default']
PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default']
PlusSquareIcon: typeof import('./src/components/icons/PlusSquareIcon.vue')['default']
RepeatIcon: typeof import('./src/components/icons/RepeatIcon.vue')['default']
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Select: typeof import('primevue/select')['default']
SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
Skeleton: typeof import('primevue/skeleton')['default']
SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
Tag: typeof import('primevue/tag')['default']
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
ToggleSwitch: typeof import('primevue/toggleswitch')['default']
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
Upload: typeof import('./src/components/icons/Upload.vue')['default']
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
Video: typeof import('./src/components/icons/Video.vue')['default']
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
VueHead: typeof import('./src/components/VueHead.tsx')['default']
WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
}
}
// For TSX support
declare global {
const ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
const Add: typeof import('./src/components/icons/Add.vue')['default']
const AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.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 Bell: typeof import('./src/components/icons/Bell.vue')['default']
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
const Button: typeof import('primevue/button')['default']
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
const Checkbox: typeof import('primevue/checkbox')['default']
@@ -81,44 +111,66 @@ declare global {
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 CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['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('primevue/dialog')['default']
const DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
const FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
const FloatLabel: typeof import('primevue/floatlabel')['default']
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
const Globe: typeof import('./src/components/icons/Globe.vue')['default']
const GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
const HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
const Home: typeof import('./src/components/icons/Home.vue')['default']
const IconField: typeof import('primevue/iconfield')['default']
const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
const InputIcon: typeof import('primevue/inputicon')['default']
const InputNumber: typeof import('primevue/inputnumber')['default']
const InputText: typeof import('primevue/inputtext')['default']
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
const Message: typeof import('primevue/message')['default']
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
const Paginator: typeof import('primevue/paginator')['default']
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
const Password: typeof import('primevue/password')['default']
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
const PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default']
const PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default']
const PlusSquareIcon: typeof import('./src/components/icons/PlusSquareIcon.vue')['default']
const RepeatIcon: typeof import('./src/components/icons/RepeatIcon.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 Select: typeof import('primevue/select')['default']
const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
const Skeleton: typeof import('primevue/skeleton')['default']
const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
const Tag: typeof import('primevue/tag')['default']
const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
const ToggleSwitch: typeof import('primevue/toggleswitch')['default']
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
const UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
const Video: typeof import('./src/components/icons/Video.vue')['default']
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
const VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
const VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
const VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
const VueHead: typeof import('./src/components/VueHead.tsx')['default']
const WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
const XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
}

View File

@@ -1,8 +1,8 @@
<script lang="ts" setup>
import Bell from "@/components/icons/Bell.vue";
import Credit from "@/components/icons/Credit.vue";
import Home from "@/components/icons/Home.vue";
import Video from "@/components/icons/Video.vue";
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
// import Upload from "@/components/icons/Upload.vue";
import { cn } from "@/lib/utils";
import { createStaticVNode, ref } from "vue";
@@ -10,10 +10,6 @@ import NotificationDrawer from "./NotificationDrawer.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);
@@ -26,9 +22,8 @@ const links = [
{ href: "/", label: "Overview", icon: Home, type: "a", className },
// { href: "/upload", label: "Upload", icon: Upload, type: "a", className },
{ href: "/videos", label: "Videos", 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' },
{ href: "/settings", label: "Settings", icon: SettingsIcon, type: "a", className },
];

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="M3 3v18h18" />
<path d="m19 9-5 5-4-4-3 3" />
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="#a6acb9"/><path d="M138 124c-71 0-128 57-128 128s57 128 128 128v96c0 18 14 32 32 32h32c18 0 32-14 32-32V124h-96z" fill="#1e3050"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="518" viewBox="-10 -244 500 518"><path d="M461-229c12 5 19 16 19 29v416c0 13-7 24-19 29-11 5-25 3-34-5l-47-41c-43-38-98-60-156-63v96c0 18-14 32-32 32h-32c-18 0-32-14-32-32v-96C57 136 0 79 0 8s57-128 128-128h85c61 0 121-23 167-63l47-41c9-8 23-10 34-5zM224 72c70 3 138 29 192 74v-276c-54 45-122 71-192 74V72z" fill="currentColor"/></svg>
</template>
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
</template>

View File

@@ -3,7 +3,7 @@
<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" />
fill="currentColor" />
</svg>
</template>
<script lang="ts" setup>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</template>

View File

@@ -1,3 +1,11 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 532 532"><path d="M10 266c0 37 21 69 51 85-10 33-2 70 24 96s63 34 96 24c16 30 48 51 85 51s69-21 85-51c33 10 70 2 96-24s34-63 24-96c30-16 51-48 51-85s-21-69-51-85c10-33 2-70-24-96s-63-34-96-24c-16-30-48-51-85-51s-69 21-85 51c-33-10-70-2-96 24s-34 63-24 96c-30 16-51 48-51 85zm152 42c-9-10-9-25 1-34 9-9 25-9 34 0l36 37 106-145c8-11 23-14 33-6 11 8 13 23 6 34L255 363c-4 5-11 9-18 10-7 0-14-3-19-8l-56-57z" fill="#a6acb9"/><path d="M339 166c8-11 23-14 33-6 11 8 13 23 6 34L255 363c-4 5-11 9-18 10-7 0-14-3-19-8l-56-57c-9-10-9-25 1-34 9-9 25-9 34 0l36 37 106-145z" fill="#1e3050"/></svg>
<svg 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" />
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9"/>
<path d="M12 16V8"/>
<path d="M9.5 10a2.5 2.5 0 0 1 5 0v4a2.5 2.5 0 0 1-5 0"/>
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" x2="12" y1="15" y2="3"/>
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="524" height="524" viewBox="-10 -242 524 524"><path d="M252-232C113-232 0-119 0 20s113 252 252 252S504 159 504 20 391-232 252-232zM37 2c7-92 73-168 161-191-42 55-68 122-71 191H37zm0 36h89c4 69 30 136 71 191-87-23-153-98-160-191zm213 198c-50-52-83-125-87-198h179c-5 73-37 146-88 198h-4zM378 38h89c-7 92-73 168-161 191 42-55 68-122 71-191zm0 0zm0-36c-4-69-30-136-71-191 87 23 153 99 160 191h-89zM254-196c51 53 83 125 87 198H163c4-73 36-145 87-198h4z" fill="currentColor"/></svg>
</template>

View File

@@ -0,0 +1,14 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z" />
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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 width="18" height="18" x="3" y="3" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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 width="18" height="18" x="3" y="3" rx="2" ry="2" />
<line x1="3" x2="21" y1="9" y2="9" />
<line x1="9" x2="9" y1="21" y2="9" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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 width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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 width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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 width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
</template>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="5 3 19 12 5 21 5 3" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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 width="18" height="18" x="3" y="3" rx="2" />
<path d="M12 8v8" />
<path d="M8 12h8" />
</svg>
</template>

View File

@@ -0,0 +1,14 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
</template>

View File

@@ -1,9 +1,5 @@
<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-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="#a6acb9"/><path d="M283 206c46 0 84 37 84 84 0 46-37 84-83 84-47 0-85-37-85-84 0-46 37-84 84-84zm1 196c61 0 111-51 111-112 0-62-51-112-112-112-62 0-112 51-112 112 0 62 51 112 113 112z" fill="#1e3050"/></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

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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 width="20" height="16" x="2" y="4" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M16 10h.01" />
<path d="M12 10h.01" />
<path d="M2 14h20" />
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="516" height="516" viewBox="-2 -250 516 516"><path d="M256-240C119-240 8-129 8 8s111 248 248 248S504 145 504 8 393-240 256-240zM371-71c-4 39-20 134-28 178-4 19-10 25-17 25-14 2-25-9-39-18-22-15-34-23-56-37-24-17-8-25 6-40 3-4 67-61 68-67 0 0 0-3-1-4-2-1-4-1-5-1-2 1-37 24-105 70-10 6-19 10-27 9-9 0-26-5-38-9-16-5-28-7-27-16 0-4 7-9 18-14 73-31 121-52 145-62 69-29 83-34 92-34 2 0 7 1 10 3 2 2 3 4 3 7 1 3 1 6 1 10z" fill="currentColor"/></svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" x2="12" y1="3" y2="15" />
</svg>
</template>

View File

@@ -0,0 +1,10 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 531"><path d="M10 150c1 99 41 281 214 363 16 8 36 8 52 0 173-82 214-264 214-363 0-26-16-48-38-57L263 13c-4-2-9-3-13-3s-8 1-12 3L48 93c-22 9-38 31-38 57zm128 212c0-44 36-80 80-80h64c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16zm168-176c0 31-25 56-56 56s-56-25-56-56 25-56 56-56 56 25 56 56z" fill="#a6acb9"/><path d="M282 282c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16 0-44 36-80 80-80h64zm-32-40c-31 0-56-25-56-56s25-56 56-56 56 25 56 56-25 56-56 56z" fill="#1e3050"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="518" height="532" viewBox="-3 -258 518 532"><path d="M368 120h-33l-22-64H199l-21 64h-34l32-96h160l32 96zM256-8c-35 0-64-29-64-64s29-64 64-64c36 0 64 29 64 64S292-8 256-8zm0-96c-17 0-32 14-32 32s15 32 32 32c18 0 32-14 32-32s-14-32-32-32zm0 368-12-5C92 193 7 26 17-135l1-20 238-93 239 93 1 20c9 161-76 328-227 394l-13 5zM49-133c-7 147 67 302 207 362 140-60 215-215 208-362l-208-81-207 81z" fill="#1e3050"/></svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 564 468"><path d="M42 170h241c-40 35-65 87-65 144 0 17 2 33 6 48H74c-18 0-32-14-32-32V170z" fill="#a6acb9"/><path d="M458 42H345l-96 96h84c-18 8-35 19-50 32H42v160c0 18 14 32 32 32h150c3 11 7 22 11 32H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3V74c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM43 138l96-96H74c-18 0-32 14-32 32v64h1zm46 0h114l96-96H185l-96 96zm321 288c62 0 112-50 112-112s-50-112-112-112-112 50-112 112 50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144 64-144 144-144zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V258c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="#1e3050"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="564" height="468" viewBox="22 -194 564 468"><path d="M480-152H367l-96 96h84c-18 8-35 19-50 32H64v160c0 18 14 32 32 32h150c3 11 7 22 11 32H96c-35 0-64-29-64-64v-256c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3v-64c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM65-56l96-96H96c-18 0-32 14-32 32v64h1zm46 0h114l96-96H207l-96 96zm321 288c62 0 112-50 112-112S494 8 432 8 320 58 320 120s50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144S352-24 432-24zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V64c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="#1e3050"/></svg>
</template>
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>

View File

@@ -0,0 +1,14 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="4" x2="20" y1="21" y2="21" />
<polygon points="12 11 4 18 4 6 12 11" />
<path d="M16 8.73a2 2 0 0 1 0 3.55" />
<path d="M18 5.05a6 6 0 0 1 0 10.9" />
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
</template>

View File

@@ -0,0 +1,14 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<line x1="12" x2="12.01" y1="20" y2="20" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg 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="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</template>

View File

@@ -24,12 +24,10 @@ const routes: RouteData[] = [
{
path: "",
component: () => import("./home/Home.vue"),
beforeEnter: (to, from, next) => {
beforeEnter: (to, from) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
return { name: "overview" };
}
},
},
@@ -48,12 +46,10 @@ const routes: RouteData[] = [
{
path: "",
component: () => import("./auth/layout.vue"),
beforeEnter: (to, from, next) => {
beforeEnter: (to, from) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
return { name: "overview" };
}
},
children: [
@@ -130,22 +126,6 @@ const routes: RouteData[] = [
// },
],
},
{
path: "payments-and-plans",
name: "payments-and-plans",
component: () => import("./plans/Plans.vue"),
meta: {
head: {
title: "Payments & Plans - Holistream",
meta: [
{
name: "description",
content: "Manage your plans and billing information.",
},
],
},
},
},
{
path: "notification",
name: "notification",
@@ -157,14 +137,99 @@ const routes: RouteData[] = [
},
},
{
path: "profile",
name: "profile",
component: () => import("./profile/Profile.vue"), // TODO: create profile page
path: "settings",
name: "settings",
component: () => import("./settings/Settings.vue"),
meta: {
head: {
title: "Profile - Holistream",
title: "Settings - Holistream",
meta: [
{
name: "description",
content: "Manage your account settings and preferences.",
},
],
},
},
redirect: '/settings/security',
children: [
{
path: "security",
name: "settings-security",
component: () => import("./settings/pages/SecurityNConnected.vue"),
meta: {
head: {
title: "Security & Connected Apps - Holistream",
},
},
},
{
path: "billing",
name: "settings-billing",
component: () => import("./settings/pages/Billing.vue"),
meta: {
head: {
title: "Billing & Plans - Holistream",
meta: [
{
name: "description",
content: "Manage your plans and billing information.",
},
],
},
},
},
{
path: "notifications",
name: "settings-notifications",
component: () => import("./settings/pages/NotificationSettings.vue"),
meta: {
head: {
title: "Notifications - Holistream",
},
},
},
{
path: "player",
name: "settings-player",
component: () => import("./settings/pages/PlayerSettings.vue"),
meta: {
head: {
title: "Player Settings - Holistream",
},
},
},
{
path: "domains",
name: "settings-domains",
component: () => import("./settings/pages/DomainsDns.vue"),
meta: {
head: {
title: "Allowed Domains - Holistream",
},
},
},
{
path: "ads",
name: "settings-ads",
component: () => import("./settings/pages/AdsVast.vue"),
meta: {
head: {
title: "Ads & VAST - Holistream",
},
},
},
{
path: "danger",
name: "settings-danger",
component: () => import("./settings/pages/DangerZone.vue"),
meta: {
head: {
title: "Danger Zone - Holistream",
},
},
},
],
},
],
},
@@ -190,18 +255,14 @@ const createAppRouter = () => {
},
});
router.beforeEach((to, from, next) => {
router.beforeEach((to, from) => {
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();
return { name: "login" };
}
} else {
next();
}
});
return router;

View File

@@ -1,205 +0,0 @@
<script setup lang="ts">
import { client, type ModelPlan } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import { computed, 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 ahihi = defineBasicLoader('/payments-and-plans', async to => {
// return client.plans.plansList();
// })
// const { data, isLoading, reload } = ahihi();
const { data, isPending, isLoading, refresh } = useQuery({
// unique key for the query in the cache
key: () => ['payments-and-plans'],
query: () => client.plans.plansList(),
})
const auth = useAuthStore();
// const plans = ref<ModelPlan[]>([]);
const subscribing = ref<string | null>(null);
const showManageDialog = ref(false);
const cancelling = ref(false);
// 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' },
]);
// Computed Usage (Mock if not in store)
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
// Default limit 10GB if no plan
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(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id; // Fallback to first plan
return undefined;
});
const currentPlan = computed(() => {
if (!Array.isArray(data?.value?.data?.data.plans)) return undefined;
return data.value.data.data.plans.find(p => p.id === currentPlanId.value);
});
// watch(data, (newValue) => {
// if (newValue) {
// // Handle potentially different response structures
// // Safe access to avoid SSR crash if data is null/undefined
// const plansList = newValue?.data?.data?.plans;
// if (Array.isArray(plansList)) {
// plans.value = plansList;
// }
// }
// }, { immediate: true });
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;
// Optimistic update or API call
await client.request({
path: `/plans/${updatedPlan.id}`,
method: 'PUT',
body: updatedPlan
});
// Refresh plans
await refresh();
showEditDialog.value = false;
alert('Plan updated successfully');
} catch (e: any) {
console.error('Failed to update plan', e);
// Fallback: update local state if API is mocked/missing
const idx = data.value!.data.data.plans.findIndex(p => p.id === updatedPlan.id);
if (idx !== -1) {
data.value!.data.data.plans[idx] = { ...updatedPlan };
}
showEditDialog.value = false;
// alert('Note: API update failed, updated locally. ' + e.message);
} 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
});
// Update local state mock
// In real app, we would re-fetch user profile
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 {
// Simulate API call
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="data?.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>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
defineProps<{
currentPlan?: ModelPlan;
}>();
defineEmits<{
(e: 'manage'): void;
}>();
</script>
<template>
<div class=":uno: 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" class="px-3" rounded></Tag>
</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" severity="secondary" class="bg-white/10 border-white/10 text-white hover:bg-white/20" @click="$emit('manage')" />
</div>
</div>
</div>
</template>

View File

@@ -1,90 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import Button from 'primevue/button';
import Checkbox from 'primevue/checkbox';
import Dialog from 'primevue/dialog';
import InputNumber from 'primevue/inputnumber';
import InputText from 'primevue/inputtext';
import Textarea from 'primevue/textarea';
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" modal 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>
<InputText id="plan-name" v-model="localPlan.name" placeholder="Plan Name" />
</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>
<InputNumber id="plan-price" v-model="localPlan.price" mode="currency" currency="USD" locale="en-US" :minFractionDigits="2" />
</div>
<div class="flex flex-col gap-2">
<label for="plan-cycle" class="text-sm font-medium text-gray-700">Billing Cycle</label>
<InputText id="plan-cycle" v-model="localPlan.cycle" placeholder="e.g. month, year" />
</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" class="w-full" />
</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>
<InputNumber id="plan-storage" v-model="localPlan.storage_limit" />
</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>
<InputNumber id="plan-uploads" v-model="localPlan.upload_limit" />
</div>
<div class="flex flex-col gap-2">
<label for="plan-duration" class="text-sm font-medium text-gray-700">Duration Limit (sec)</label>
<InputNumber id="plan-duration" v-model="localPlan.duration_limit" />
</div>
</div>
<div class="flex items-center gap-2 pt-2">
<Checkbox v-model="localPlan.is_active" :binary="true" inputId="plan-active" />
<label for="plan-active" class="text-sm font-medium text-gray-700">Active</label>
</div>
</div>
<template #footer>
<Button label="Cancel" text severity="secondary" @click="visibleModel = false" />
<Button label="Save Changes" icon="i-heroicons-check" @click="onSave" :loading="loading" />
</template>
</Dialog>
</template>

View File

@@ -1,57 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import Button from 'primevue/button';
import Dialog from 'primevue/dialog';
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" modal 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 label="Close" text severity="secondary" @click="visibleModel = false" />
<Button
label="Cancel Subscription"
severity="danger"
:icon="cancelling ? 'i-svg-spinners-180-ring-with-bg' : 'i-heroicons-x-circle'"
@click="emit('cancel-subscription')"
:disabled="cancelling"
/>
</div>
</Dialog>
</template>

View File

@@ -1,107 +0,0 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import Button from 'primevue/button';
import Skeleton from 'primevue/skeleton';
import { formatBytes } from '@/lib/utils'; // Using utils formatBytes
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"></Skeleton>
</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"
icon="i-heroicons-pencil-square"
class="absolute top-2 right-2 z-20 !p-2 !w-8 !h-8"
severity="secondary"
text
rounded
@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')"
:icon="subscribingPlanId === plan.id ? 'i-svg-spinners-180-ring-with-bg' : ''"
class="w-full"
:severity="isCurrentComp(plan, currentPlanId) ? 'secondary' : 'primary'"
:outlined="isCurrentComp(plan, currentPlanId)"
:disabled="!!subscribingPlanId || isCurrentComp(plan, currentPlanId)"
@click="emit('subscribe', plan)"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,93 +0,0 @@
<script setup lang="ts">
import Button from 'primevue/button';
import Column from 'primevue/column';
import DataTable from 'primevue/datatable';
import Tag from 'primevue/tag';
interface PaymentHistoryItem {
id: string;
date: string;
amount: number;
plan: string;
status: string;
invoiceId: string;
}
defineProps<{
history: PaymentHistoryItem[];
}>();
const getStatusSeverity = (status: string) => {
switch (status) {
case 'success':
return 'success';
case 'failed':
return 'danger';
case 'pending':
return 'warn';
default:
return 'info';
}
};
import { useToast } from 'primevue/usetoast';
import ArrowDownTray from '@/components/icons/ArrowDownTray.vue';
const toast = useToast();
const downloadInvoice = (item: PaymentHistoryItem) => {
toast.add({
severity: 'info',
summary: 'Downloading',
detail: `Downloading invoice #${item.invoiceId}...`,
life: 2000
});
// Simulate download delay
setTimeout(() => {
toast.add({
severity: 'success',
summary: 'Downloaded',
detail: `Invoice #${item.invoiceId} downloaded successfully`,
life: 3000
});
}, 1500);
};
</script>
<template>
<section>
<h2 class="text-2xl font-bold mb-6 text-gray-900">Billing History</h2>
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
<DataTable :value="history" responsiveLayout="scroll" class="w-full">
<template #empty>
<div class="text-center py-8 text-gray-500">No payment history found.</div>
</template>
<Column field="date" header="Date" class="font-medium"></Column>
<Column field="amount" header="Amount">
<template #body="slotProps">
${{ slotProps.data.amount }}
</template>
</Column>
<Column field="plan" header="Plan"></Column>
<Column field="status" header="Status">
<template #body="slotProps">
<Tag :value="slotProps.data.status" :severity="getStatusSeverity(slotProps.data.status)"
class="capitalize px-2 py-0.5 text-xs" :rounded="true" />
</template>
</Column>
<!-- <Column header="" style="width: 3rem">
<template #body="slotProps">
<Button text rounded severity="secondary" size="small" @click="downloadInvoice(slotProps.data)"
v-tooltip="'Download Invoice'">
<template #icon>
<ArrowDownTray class="w-5 h-5" />
</template>
</Button>
</template>
</Column> -->
</DataTable>
</div>
</section>
</template>

View File

@@ -1,39 +0,0 @@
<script setup lang="ts">
import { formatBytes } from '@/lib/utils';
import ProgressBar from 'primevue/progressbar';
import { computed } from 'vue';
const props = defineProps<{
storageUsed: number;
storageLimit: number;
uploadsUsed: number;
uploadsLimit: number;
}>();
const storagePercentage = computed(() => Math.min(Math.round((props.storageUsed / props.storageLimit) * 100), 100));
const uploadsPercentage = computed(() => Math.min(Math.round((props.uploadsUsed / props.uploadsLimit) * 100), 100));
</script>
<template>
<div class="bg-white border border-gray-200 rounded-2xl p-8 flex flex-col justify-center">
<h3 class="text-lg font-bold text-gray-900 mb-6">Usage Statistics</h3>
<div class="mb-6">
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-600 font-medium">Storage</span>
<span class="text-gray-900 font-bold">{{ storagePercentage }}%</span>
</div>
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 8px" :class="storagePercentage > 90 ? 'p-progressbar-danger' : ''"></ProgressBar>
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
</div>
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-600 font-medium">Monthly Uploads</span>
<span class="text-gray-900 font-bold">{{ uploadsPercentage }}%</span>
</div>
<ProgressBar :value="uploadsPercentage" :showValue="false" style="height: 8px"></ProgressBar>
<p class="text-xs text-gray-500 mt-2">{{ uploadsUsed }} of {{ uploadsLimit }} uploads</p>
</div>
</div>
</template>

View File

@@ -1,106 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import ProfileHero from './components/ProfileHero.vue';
import ProfileInfoCard from './components/ProfileInfoCard.vue';
import ChangePasswordDialog from './components/ChangePasswordDialog.vue';
import AccountStatusCard from './components/AccountStatusCard.vue';
import LinkedAccountsCard from './components/LinkedAccountsCard.vue';
import { useToast } from 'primevue/usetoast';
const auth = useAuthStore();
const toast = useToast();
// Dialog visibility
const showPasswordDialog = ref(false);
// Refs for dialog components
const passwordDialogRef = ref<InstanceType<typeof ChangePasswordDialog>>();
// Computed storage values
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240); // 10GB default
// Handlers
const handleEditSave = async (data: { username: string; email: string }) => {
try {
await auth.updateProfile(data);
toast.add({
severity: 'success',
summary: 'Profile Updated',
detail: 'Your profile has been updated successfully.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Update Failed',
detail: auth.error || 'Failed to update profile.',
life: 5000
});
}
};
const handlePasswordSave = async (data: { currentPassword: string; newPassword: string }) => {
try {
await auth.changePassword(data.currentPassword, data.newPassword);
showPasswordDialog.value = false;
toast.add({
severity: 'success',
summary: 'Password Changed',
detail: 'Your password has been changed successfully.',
life: 3000
});
} catch (e: any) {
passwordDialogRef.value?.setError(e.message || 'Failed to change password');
}
};
</script>
<template>
<div class="profile-page">
<PageHeader
title="Profile Settings"
description="Manage your account information and preferences."
:breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Profile' }
]"
/>
<div class="max-w-5xl mx-auto space-y-8 pb-12">
<!-- Hero Identity Card -->
<ProfileHero
:user="auth.user"
@logout="auth.logout()"
/>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Personal Info -->
<div class="md:col-span-2">
<ProfileInfoCard
:user="auth.user"
@change-password="showPasswordDialog = true"
/>
</div>
<!-- Stats Side -->
<div class="md:col-span-1 space-y-6">
<AccountStatusCard
:storage-used="storageUsed"
:storage-limit="storageLimit"
/>
<LinkedAccountsCard />
</div>
</div>
</div>
<!-- Dialogs -->
<ChangePasswordDialog
ref="passwordDialogRef"
v-model:visible="showPasswordDialog"
@save="handlePasswordSave"
/>
</div>
</template>

View File

@@ -1,47 +0,0 @@
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar';
import { computed } from 'vue';
const props = defineProps<{
storageUsed: number;
storageLimit: number;
}>();
const storagePercentage = computed(() =>
Math.min(Math.round((props.storageUsed / props.storageLimit) * 100), 100)
);
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
</script>
<template>
<div class="bg-white border border-gray-200 rounded-2xl p-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">Account Status</h3>
<div class="space-y-4">
<div>
<div class="flex justify-between text-sm mb-2">
<span class="text-gray-600">Storage Used</span>
<span class="font-bold text-gray-900">{{ storagePercentage }}%</span>
</div>
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 6px"></ProgressBar>
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
</div>
<div class="bg-green-50 rounded-lg p-4 border border-green-100 flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-green-600 mt-0.5 shrink-0" 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"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<div>
<h4 class="font-bold text-green-800 text-sm">Account Active</h4>
<p class="text-green-600 text-xs mt-0.5">Your subscription is in good standing.</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,102 +0,0 @@
<script setup lang="ts">
import Dialog from 'primevue/dialog';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import Message from 'primevue/message';
import { ref, computed, watch } from 'vue';
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
save: [data: { currentPassword: string; newPassword: string }];
}>();
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const loading = ref(false);
const error = ref('');
watch(() => props.visible, (val) => {
if (val) {
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
error.value = '';
}
});
const isValid = computed(() => {
return currentPassword.value.length >= 1
&& newPassword.value.length >= 6
&& newPassword.value === confirmPassword.value;
});
const passwordMismatch = computed(() => {
return confirmPassword.value.length > 0 && newPassword.value !== confirmPassword.value;
});
const passwordTooShort = computed(() => {
return newPassword.value.length > 0 && newPassword.value.length < 6;
});
const handleSave = () => {
if (!isValid.value) return;
loading.value = true;
error.value = '';
emit('save', {
currentPassword: currentPassword.value,
newPassword: newPassword.value
});
};
const handleClose = () => {
emit('update:visible', false);
};
// Expose methods for parent to control loading state
defineExpose({
setLoading: (val: boolean) => { loading.value = val; },
setError: (msg: string) => { error.value = msg; loading.value = false; }
});
</script>
<template>
<Dialog :visible="visible" @update:visible="emit('update:visible', $event)" modal header="Change Password"
:style="{ width: '28rem' }" :closable="true" :draggable="false">
<div class="space-y-6 pt-2">
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
<div class="flex flex-col gap-2">
<label for="current-password" class="text-sm font-medium text-gray-700">Current Password</label>
<InputText id="current-password" v-model="currentPassword" type="password" class="w-full"
placeholder="Enter current password" />
</div>
<div class="flex flex-col gap-2">
<label for="new-password" class="text-sm font-medium text-gray-700">New Password</label>
<InputText id="new-password" v-model="newPassword" type="password" class="w-full"
placeholder="Enter new password (min 6 characters)"
:class="{ 'p-invalid': passwordTooShort }" />
<small v-if="passwordTooShort" class="text-red-500">Password must be at least 6 characters</small>
</div>
<div class="flex flex-col gap-2">
<label for="confirm-password" class="text-sm font-medium text-gray-700">Confirm New Password</label>
<InputText id="confirm-password" v-model="confirmPassword" type="password" class="w-full"
placeholder="Confirm new password"
:class="{ 'p-invalid': passwordMismatch }" />
<small v-if="passwordMismatch" class="text-red-500">Passwords do not match</small>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3 pt-4">
<Button label="Cancel" severity="secondary" @click="handleClose" :disabled="loading" />
<Button label="Change Password" @click="handleSave" :loading="loading" :disabled="!isValid" />
</div>
</template>
</Dialog>
</template>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import Tag from 'primevue/tag';
</script>
<template>
<div class="bg-white border border-gray-200 rounded-2xl p-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">Linked Accounts</h3>
<div class="space-y-3">
<div class="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-gray-200 transition-colors">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-red-600" viewBox="0 0 24 24">
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
</div>
<span class="font-medium text-gray-700">Google</span>
</div>
<Tag value="Connected" severity="success" class="text-xs px-2"></Tag>
</div>
</div>
</div>
</template>

View File

@@ -1,83 +0,0 @@
<script setup lang="ts">
import type { ModelUser } from '@/api/client';
import Avatar from 'primevue/avatar';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import { computed } from 'vue';
const props = defineProps<{
user: ModelUser | null;
}>();
const emit = defineEmits<{
logout: [];
changePassword: [];
}>();
const joinDate = computed(() => {
return new Date(props.user?.created_at || Date.now()).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
});
</script>
<template>
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-r from-gray-900 via-gray-800 to-gray-900 text-white p-8 md:p-10">
<!-- Background decorations -->
<div class="absolute top-0 right-0 -mt-20 -mr-20 w-80 h-80 bg-primary-500 rounded-full mix-blend-overlay filter blur-3xl"></div>
<div class="absolute bottom-0 left-0 -mb-20 -ml-20 w-80 h-80 bg-purple-500 rounded-full mix-blend-overlay filter blur-3xl"></div>
<div class="relative z-10 flex flex-col md:flex-row items-center gap-8">
<div class="relative">
<div class="absolute inset-0 bg-primary-500 rounded-full blur-lg opacity-40"></div>
<!-- :label="user?.username?.charAt(0).toUpperCase() || 'U'" -->
<Avatar
class="relative border-4 border-gray-800 text-3xl font-bold bg-gradient-to-br from-primary-400 to-primary-600 text-white shadow-2xl"
size="xlarge"
shape="circle"
style="width: 120px; height: 120px; font-size: 3rem;"
image="https://picsum.photos/seed/user123/120/120.jpg"
/>
</div>
<div class="text-center md:text-left space-y-2 flex-grow">
<div class="flex flex-col md:flex-row items-center gap-3 justify-center md:justify-start">
<h2 class="text-3xl font-bold text-white">{{ user?.username || 'User' }}</h2>
<Tag :value="user?.role || 'User'" severity="info" class="uppercase tracking-wider px-2 header-tag" rounded></Tag>
</div>
<p class="text-gray-400 text-lg">{{ user?.email }}</p>
<p class="text-gray-500 text-sm flex items-center justify-center md:justify-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="4" rx="2" ry="2"/>
<line x1="16" x2="16" y1="2" y2="6"/>
<line x1="8" x2="8" y1="2" y2="6"/>
<line x1="3" x2="21" y1="10" y2="10"/>
</svg>
Member since {{ joinDate }}
</p>
</div>
<div class="flex gap-3">
<Button label="Logout" severity="danger" class="border-white/10 text-white hover:bg-white/10 bg-white/5" @click="emit('logout')">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" x2="9" y1="12" y2="12"/>
</svg>
</template>
</Button>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.header-tag) {
background: rgba(255,255,255,0.2) !important;
color: white !important;
border: 1px solid rgba(255,255,255,0.1);
}
</style>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import type { ModelUser } from '@/api/client';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
defineProps<{
user: ModelUser | null;
}>();
const emit = defineEmits<{
edit: [];
changePassword: [];
}>();
</script>
<template>
<div class="bg-white border border-gray-200 rounded-2xl p-8">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-bold text-gray-900">Personal Information</h3>
<div class="flex gap-2">
<Button label="Change Password" text severity="secondary" @click="emit('changePassword')">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</template>
</Button>
</div>
</div>
<div class="grid grid-cols-1 gap-6">
<div class="flex flex-col gap-2">
<label for="username" class="text-sm font-medium text-gray-700">Username</label>
<div class="relative">
<IconField>
<InputIcon>
<svg 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="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
</InputIcon>
<InputText id="username" :value="user?.username" class="w-full pl-10" readonly />
</IconField>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="email" class="text-sm font-medium text-gray-700">Email Address</label>
<div class="relative">
<IconField>
<InputIcon>
<svg 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">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</InputIcon>
<InputText id="email" :value="user?.email" class="w-full pl-10" readonly />
</IconField>
</div>
</div>
<div class="grid grid-cols-2 gap-6">
<!-- <div class="flex flex-col gap-2">
<label for="role" class="text-sm font-medium text-gray-700">Role</label>
<InputText id="role" :value="user?.role || 'User'" class="w-full capitalize bg-gray-50" readonly />
</div> -->
<!-- <div class="flex flex-col gap-2">
<label for="id" class="text-sm font-medium text-gray-700">User ID</label>
<InputText id="id" :value="user?.id || 'N/A'" class="w-full font-mono text-sm bg-gray-50" readonly />
</div> -->
</div>
</div>
</div>
</template>
<style scoped>
:deep(.p-inputtext[readonly]) {
background-color: #f9fafb;
border-color: #e5e7eb;
color: #374151;
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<section>
<PageHeader
:title="content[route.name as keyof typeof content]?.title || 'Settings'"
:description="content[route.name as keyof typeof content]?.subtitle || 'Manage your account settings and preferences.'"
:breadcrumbs="breadcrumbs"
/>
<div class="max-w-7xl mx-auto pb-12">
<div class="flex flex-col md:flex-row gap-8 mt-6">
<!-- Sidebar Navigation (GitHub-style) -->
<aside class="md:w-56 shrink-0">
<div class="flex items-center gap-4 mb-8">
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || 'User' }}</h3>
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div>
</div>
<nav class="space-y-6">
<div v-for="section in menuSections" :key="section.title">
<h3 v-if="section.title" class="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-2 pl-3">
{{ section.title }}
</h3>
<ul class="space-y-0.5">
<li v-for="item in section.items" :key="item.value">
<router-link
:to="tabPaths[item.value]"
:class="[
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
currentTab === item.value
? 'bg-primary/10 text-primary font-semibold'
: item.danger
? 'text-danger hover:bg-danger/10'
: 'text-foreground/70 hover:bg-muted hover:text-foreground'
]"
>
<component :is="item.icon" class="w-5 h-5 shrink-0" :filled="currentTab === item.value" />
{{ item.label }}
</router-link>
</li>
</ul>
</div>
</nav>
</aside>
<!-- Main Content Area -->
<main class="flex-1 min-w-0">
<router-view />
</main>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import UserIcon from '@/components/icons/UserIcon.vue';
import GlobeIcon from '@/components/icons/Globe.vue';
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
import AlertTriangle from '@/components/icons/AlertTriangle.vue';
import { useAuthStore } from '@/stores/auth';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
import Bell from '@/components/icons/Bell.vue';
import VideoIcon from '@/components/icons/VideoIcon.vue';
import AdvertisementIcon from '@/components/icons/AdvertisementIcon.vue';
import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue';
const route = useRoute();
const auth = useAuthStore();
// Map tab values to their paths
const tabPaths: Record<string, string> = {
profile: '/settings',
security: '/settings/security',
notifications: '/settings/notifications',
player: '/settings/player',
billing: '/settings/billing',
domains: '/settings/domains',
ads: '/settings/ads',
danger: '/settings/danger',
};
// Menu items grouped by category (GitHub-style)
const menuSections: { title?: string; items: { value: string; label: string; icon: any; danger?: boolean }[] }[] = [
{
title: 'Security',
items: [
{ value: 'security', label: 'Security', icon: UserIcon },
{ value: 'billing', label: 'Billing & Plans', icon: CreditCardIcon },
],
},
{
title: 'Preferences',
items: [
{ value: 'notifications', label: 'Notifications', icon: Bell },
{ value: 'player', label: 'Player', icon: VideoPlayIcon },
],
},
{
title: 'Integrations',
items: [
{ value: 'domains', label: 'Allowed Domains', icon: GlobeIcon },
{ value: 'ads', label: 'Ads & VAST', icon: AdvertisementIcon },
],
},
{
title: 'Danger Zone',
items: [
{ value: 'danger', label: 'Danger Zone', icon: AlertTriangle, danger: true },
],
},
] as const;
type TabValue = typeof menuSections[number]['items'][number]['value'];
// Get current tab from route path
const currentTab = computed<TabValue>(() => {
const path = route.path as string;
const tabName = path.replace('/settings', '') || '/profile';
if (tabName === '' || tabName === '/') return 'profile';
return (tabName.replace('/', '') as TabValue) || 'profile';
});
// Breadcrumbs with dynamic tab
const allMenuItems = menuSections.flatMap(section => section.items);
const currentItem = allMenuItems.find(item => item.value === currentTab.value);
const breadcrumbs = [
{ label: 'Dashboard', to: '/overview' },
{ label: 'Settings', to: '/settings' },
...(currentItem ? [{ label: currentItem.label }] : []),
];
const content = {
security: {
title: 'Security & Connected Apps',
subtitle: 'Manage your security settings and connected applications.'
},
notifications: {
title: 'Notifications',
subtitle: 'Choose how you want to receive notifications and updates.'
},
player: {
title: 'Player Settings',
subtitle: 'Configure default video player behavior and features.'
},
billing: {
title: 'Billing & Plans',
subtitle: 'Your current subscription and billing information.'
},
domains: {
title: 'Allowed Domains',
subtitle: 'Add domains to your whitelist to allow embedding content via iframe.'
},
ads: {
title: 'Ads & VAST',
subtitle: 'Create and manage VAST ad templates for your videos.'
},
danger: {
title: 'Danger Zone',
subtitle: 'Irreversible and destructive actions. Be careful!'
}
}
</script>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import CheckIcon from '@/components/icons/CheckIcon.vue';
const props = defineProps<{
plans: ModelPlan[];
isLoading: boolean;
currentPlanId?: string;
subscribingPlanId?: string | null;
}>();
const emit = defineEmits<{
(e: 'subscribe', plan: ModelPlan): void;
}>();
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 mins';
return `${Math.floor(seconds / 60)} mins`;
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Available Plans</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Choose the plan that best fits your needs.
</p>
</div>
<div class="p-6">
<!-- Loading State -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i">
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="plan in plans"
:key="plan.id"
class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-all"
>
<div class="mb-3">
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
</div>
<div class="mb-4">
<span class="text-2xl font-bold text-foreground">${{ plan.price }}</span>
<span class="text-foreground/60 text-sm">/{{ plan.cycle }}</span>
</div>
<ul class="space-y-2 mb-4 text-sm">
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatBytes(plan.storage_limit || 0) }} Storage
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatDuration(plan.duration_limit) }} Max Duration
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ plan.upload_limit }} Uploads / day
</li>
</ul>
<button
:disabled="!!subscribingPlanId || plan.id === currentPlanId"
:class="[
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all',
plan.id === currentPlanId
? 'bg-muted/50 text-foreground/60 cursor-not-allowed'
: subscribingPlanId === plan.id
? 'bg-muted/50 text-foreground/60 cursor-wait'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
]"
@click="emit('subscribe', plan)"
>
{{ plan.id === currentPlanId ? 'Current Plan' : (subscribingPlanId === plan.id ? 'Processing...' : 'Upgrade') }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,196 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import InputText from 'primevue/inputtext';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
const toast = useToast();
const props = defineProps<{
dialogVisible: boolean;
error: string;
loading: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
emailConnected: boolean;
telegramConnected: boolean;
telegramUsername: string;
}>();
const emit = defineEmits<{
(e: 'update:dialogVisible', value: boolean): void;
(e: 'update:currentPassword', value: string): void;
(e: 'update:newPassword', value: string): void;
(e: 'update:confirmPassword', value: string): void;
(e: 'close'): void;
(e: 'change-password'): void;
(e: 'connect-telegram'): void;
(e: 'disconnect-telegram'): void;
}>();
const handleChangePassword = () => {
emit('change-password');
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h3 class="text-sm font-semibold text-foreground mb-3">Connected Accounts</h3>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Email Connection -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-info/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Email</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ emailConnected ? 'Connected' : 'Not connected' }}
</p>
</div>
</div>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? 'Connected' : 'Disconnected' }}
</span>
</div>
<!-- Telegram Connection -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-[#0088cc]/10 flex items-center justify-center shrink-0">
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Telegram</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ telegramConnected ? (telegramUsername || 'Connected') : 'Get notified via Telegram' }}
</p>
</div>
</div>
<Button
v-if="telegramConnected"
label="Disconnect"
size="small"
text
severity="danger"
@click="$emit('disconnect-telegram')"
class="press-animated"
/>
<Button
v-else
label="Connect"
size="small"
@click="$emit('connect-telegram')"
class="press-animated"
/>
</div>
</div>
<!-- Change Password Dialog -->
<Dialog
:visible="dialogVisible"
@update:visible="$emit('update:dialogVisible', $event)"
modal
header="Change Password"
:style="{ width: '26rem' }"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Enter your current password and choose a new password.
</p>
<!-- Error Message -->
<div v-if="error" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ error }}
</div>
<!-- Current Password -->
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label>
<IconField>
<InputIcon>
<LockIcon class="w-5 h-5" />
</InputIcon>
<InputText
id="currentPassword"
:model-value="currentPassword"
type="password"
placeholder="Enter current password"
class="w-full"
@update:model-value="$emit('update:currentPassword', $event)"
/>
</IconField>
</div>
<!-- New Password -->
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">New Password</label>
<IconField>
<InputIcon>
<LockIcon class="w-5 h-5" />
</InputIcon>
<InputText
id="newPassword"
:model-value="newPassword"
type="password"
placeholder="Enter new password"
class="w-full"
@update:model-value="$emit('update:newPassword', $event)"
/>
</IconField>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label>
<IconField>
<InputIcon>
<LockIcon class="w-5 h-5" />
</InputIcon>
<InputText
id="confirmPassword"
:model-value="confirmPassword"
type="password"
placeholder="Confirm new password"
class="w-full"
@update:model-value="$emit('update:confirmPassword', $event)"
/>
</IconField>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<Button
label="Cancel"
text
severity="secondary"
@click="$emit('close')"
:disabled="loading"
class="press-animated"
/>
<Button
label="Change Password"
@click="handleChangePassword"
:loading="loading"
class="press-animated"
/>
</div>
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { type ModelPlan } from '@/api/client';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
defineProps<{
currentPlan?: ModelPlan;
}>();
defineEmits<{
(e: 'manage'): void;
}>();
</script>
<template>
<div class="bg-surface border border-border rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Current Plan</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Your current subscription and billing information.
</p>
</div>
<div class="p-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<CreditCardIcon class="w-7 h-7 text-primary" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ currentPlan?.name || 'Standard Plan' }}</h3>
<p class="text-sm text-foreground/60">${{ currentPlan?.price || 0 }}/month</p>
</div>
</div>
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-success/10 text-success">
Active
</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
interface PaymentHistoryItem {
id: string;
date: string;
amount: number;
plan: string;
status: string;
invoiceId: string;
}
defineProps<{
history: PaymentHistoryItem[];
}>();
const emit = defineEmits<{
(e: 'download', item: PaymentHistoryItem): void;
}>();
const getStatusStyles = (status: string) => {
switch (status) {
case 'success':
return 'bg-success/10 text-success';
case 'failed':
return 'bg-danger/10 text-danger';
case 'pending':
return 'bg-warning/10 text-warning';
default:
return 'bg-info/10 text-info';
}
};
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Billing History</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Your past payments and invoices.
</p>
</div>
<div class="divide-y divide-border">
<!-- Table Header -->
<div class="grid grid-cols-12 gap-4 px-6 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider">
<div class="col-span-3">Date</div>
<div class="col-span-2">Amount</div>
<div class="col-span-3">Plan</div>
<div class="col-span-2">Status</div>
<div class="col-span-2 text-right">Invoice</div>
</div>
<!-- Empty State -->
<div v-if="history.length === 0" class="text-center py-12 text-foreground/60">
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<DownloadIcon class="w-8 h-8 text-foreground/40" />
</div>
<p>No payment history found.</p>
</div>
<!-- Table Rows -->
<div
v-for="item in history"
:key="item.id"
class="grid grid-cols-12 gap-4 px-6 py-4 items-center hover:bg-muted/30 transition-all"
>
<div class="col-span-3">
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-foreground">${{ item.amount }}</p>
</div>
<div class="col-span-3">
<p class="text-sm text-foreground">{{ item.plan }}</p>
</div>
<div class="col-span-2">
<span
:class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`"
>
{{ capitalize(item.status) }}
</span>
</div>
<div class="col-span-2 flex justify-end">
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all"
@click="emit('download', item)"
>
<DownloadIcon class="w-4 h-4" />
<span>Download</span>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useToast } from 'primevue/usetoast';
import InputText from 'primevue/inputtext';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import ProgressBar from 'primevue/progressbar';
import Button from 'primevue/button';
import UserIcon from '@/components/icons/UserIcon.vue';
const auth = useAuthStore();
const toast = useToast();
const props = defineProps<{
editing: boolean;
username: string;
email: string;
saving: boolean;
}>();
const emit = defineEmits<{
(e: 'update:username', value: string): void;
(e: 'update:email', value: string): void;
(e: 'start-edit'): void;
(e: 'cancel-edit'): void;
(e: 'save'): void;
(e: 'change-password'): void;
}>();
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
);
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Profile Information</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your personal information and account details.
</p>
</div>
<!-- Content -->
<div class="p-6 space-y-6">
<!-- User Avatar & Name -->
<div class="flex items-center gap-4 pb-4 border-b border-border">
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || 'User' }}</h3>
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div>
</div>
<!-- Form Fields -->
<div class="grid gap-6 max-w-2xl">
<div class="grid gap-2">
<label for="username" class="text-sm font-medium text-foreground">Username</label>
<IconField>
<InputIcon>
<UserIcon class="w-5 h-5" />
</InputIcon>
<InputText
id="username"
:model-value="username"
:readonly="!editing"
:class="['w-full', editing ? 'bg-surface' : 'bg-muted/30']"
@update:model-value="emit('update:username', String($event))"
/>
</IconField>
</div>
<div class="grid gap-2">
<label for="email" class="text-sm font-medium text-foreground">Email Address</label>
<IconField>
<InputIcon>
<svg 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">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</InputIcon>
<InputText
id="email"
:model-value="email"
:readonly="!editing"
:class="['w-full', editing ? 'bg-surface' : 'bg-muted/30']"
@update:model-value="emit('update:email', $event|| '')"
/>
</IconField>
</div>
</div>
<!-- Storage Usage -->
<div class="pt-4 border-t border-border">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" x2="12" y1="3" y2="15"/>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-foreground">Storage Usage</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
</div>
<span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span>
</div>
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 6px" />
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 bg-muted/30 border-t border-border flex items-center gap-3">
<template v-if="editing">
<Button
label="Save Changes"
size="small"
:loading="saving"
@click="emit('save')"
class="press-animated"
/>
<Button
label="Cancel"
size="small"
text
severity="secondary"
@click="emit('cancel-edit')"
:disabled="saving"
class="press-animated"
/>
</template>
<template v-else>
<Button
label="Edit Profile"
size="small"
@click="emit('start-edit')"
class="press-animated"
/>
<Button
label="Change Password"
size="small"
text
severity="secondary"
@click="emit('change-password')"
class="press-animated"
/>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { ref, h } from 'vue';
import { useToast } from 'primevue/usetoast';
import InputText from 'primevue/inputtext';
import IconField from 'primevue/iconfield';
import InputIcon from 'primevue/inputicon';
import ToggleSwitch from 'primevue/toggleswitch';
import Dialog from 'primevue/dialog';
import Button from 'primevue/button';
import LockIcon from '@/components/icons/LockIcon.vue';
const toast = useToast();
const props = defineProps<{
twoFactorEnabled: boolean;
changePasswordError: string;
changePasswordLoading: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}>();
const emit = defineEmits<{
(e: 'update:twoFactorEnabled', value: boolean): void;
(e: 'update:currentPassword', value: string): void;
(e: 'update:newPassword', value: string): void;
(e: 'update:confirmPassword', value: string): void;
(e: 'toggle-2fa'): void;
(e: 'change-password'): void;
(e: 'close-password-dialog'): void;
(e: 'close-2fa-dialog'): void;
(e: 'confirm-2fa'): void;
}>();
const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
const handleToggle2FA = async () => {
if (!props.twoFactorEnabled) {
twoFactorDialogVisible.value = true;
} else {
emit('toggle-2fa');
}
};
const confirmTwoFactor = async () => {
emit('confirm-2fa');
twoFactorDialogVisible.value = false;
twoFactorCode.value = '';
};
const items = [
{
label: "Account Status",
description: "Your account is in good standing",
action: h(ToggleSwitch, {
modelValue: props.twoFactorEnabled,
"onUpdate:modelValue": (value: boolean) => emit('update:twoFactorEnabled', value),
onChange: handleToggle2FA
})
}
];
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Security & Connected Accounts</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your security settings and connected services.
</p>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Account Status -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" 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"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Account Status</p>
<p class="text-xs text-foreground/60 mt-0.5">Your account is in good standing</p>
</div>
</div>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">Active</span>
</div>
<!-- Two-Factor Authentication -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<LockIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Two-Factor Authentication</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ twoFactorEnabled ? '2FA is enabled' : 'Add an extra layer of security' }}
</p>
</div>
</div>
<ToggleSwitch
:model-value="twoFactorEnabled"
@update:model-value="emit('update:twoFactorEnabled', $event)"
@change="handleToggle2FA"
/>
</div>
<!-- Change Password -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<svg aria-hidden="true" class="fill-primary" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Change Password</p>
<p class="text-xs text-foreground/60 mt-0.5">
Update your account password
</p>
</div>
</div>
<Button
label="Change Password"
@click="$emit('change-password')"
size="small"
>
Change Password
</Button>
</div>
</div>
<!-- 2FA Setup Dialog -->
<Dialog
v-model:visible="twoFactorDialogVisible"
modal
header="Enable Two-Factor Authentication"
:style="{ width: '26rem' }"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<!-- QR Code Placeholder -->
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
</div>
<!-- Secret Key -->
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">Secret Key:</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<!-- Verification Code Input -->
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
<InputText
id="twoFactorCode"
v-model="twoFactorCode"
placeholder="Enter 6-digit code"
maxlength="6"
class="w-full"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<Button
label="Cancel"
text
severity="secondary"
@click="twoFactorDialogVisible = false"
class="press-animated"
/>
<Button
label="Verify & Enable"
@click="confirmTwoFactor"
class="press-animated"
/>
</div>
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue';
import UploadIcon from '@/components/icons/UploadIcon.vue';
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
const props = defineProps<{
storageUsed: number;
storageLimit: number;
uploadsUsed: number;
uploadsLimit: number;
}>();
const storagePercentage = computed(() =>
Math.min(Math.round((props.storageUsed / props.storageLimit) * 100), 100)
);
const uploadsPercentage = computed(() =>
Math.min(Math.round((props.uploadsUsed / props.uploadsLimit) * 100), 100)
);
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Usage Statistics</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Your current resource usage and limits.
</p>
</div>
<div class="p-6 space-y-6">
<!-- Storage -->
<div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<ActivityIcon class="w-4 h-4 text-accent" />
</div>
<span class="text-sm font-medium text-foreground">Storage</span>
</div>
<span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-primary h-full rounded-full transition-all duration-300"
:style="{ width: `${storagePercentage}%` }"
></div>
</div>
<p class="text-xs text-foreground/60 mt-2">
{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used
</p>
</div>
<!-- Uploads -->
<div>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<UploadIcon class="w-4 h-4 text-info" />
</div>
<span class="text-sm font-medium text-foreground">Monthly Uploads</span>
</div>
<span class="text-sm font-semibold text-foreground">{{ uploadsPercentage }}%</span>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-info h-full rounded-full transition-all duration-300"
:style="{ width: `${uploadsPercentage}%` }"
></div>
</div>
<p class="text-xs text-foreground/60 mt-2">
{{ uploadsUsed }} of {{ uploadsLimit }} uploads
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref } from 'vue';
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
const props = defineProps<{
balance: number;
}>();
const emit = defineEmits<{
(e: 'topup', amount: number): void;
}>();
const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(null);
const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100];
const openTopupDialog = () => {
topupAmount.value = null;
topupDialogVisible.value = true;
};
const selectPreset = (amount: number) => {
topupAmount.value = amount;
};
const processTopup = async () => {
if (!topupAmount.value || topupAmount.value < 1) {
return;
}
topupLoading.value = true;
try {
emit('topup', topupAmount.value);
topupDialogVisible.value = false;
topupAmount.value = null;
} finally {
topupLoading.value = false;
}
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Wallet Balance</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Your current wallet balance for subscriptions and services.
</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-md hover:bg-primary/90 transition-all press-animated"
@click="openTopupDialog"
>
<PlusIcon class="w-4 h-4" />
Top Up
</button>
</div>
<div class="p-6">
<div class="flex items-center gap-4">
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<CoinsIcon class="w-8 h-8 text-primary" />
</div>
<div>
<p class="text-sm text-foreground/60">Current Balance</p>
<p class="text-3xl font-bold text-primary">${{ balance.toFixed(2) }}</p>
</div>
</div>
</div>
<!-- Top-up Dialog -->
<Teleport to="body">
<Transition name="dialog">
<div v-if="topupDialogVisible" class="fixed inset-0 z-50 flex items-center justify-center">
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
@click="topupDialogVisible = false"
></div>
<!-- Dialog -->
<div
class="relative bg-surface border border-border rounded-lg shadow-xl w-full max-w-md mx-4 overflow-hidden"
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 class="text-lg font-semibold text-foreground">Top Up Wallet</h3>
<button
class="text-foreground/60 hover:text-foreground transition-colors"
@click="topupDialogVisible = false"
>
<XIcon class="w-5 h-5" />
</button>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<p class="text-sm text-foreground/70">
Select an amount or enter a custom amount to add to your wallet.
</p>
<!-- Preset Amounts -->
<div class="grid grid-cols-4 gap-3">
<button
v-for="preset in topupPresets"
:key="preset"
:class="[
'py-2 px-3 rounded-md text-sm font-medium transition-all',
topupAmount === preset
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 text-foreground hover:bg-muted'
]"
@click="selectPreset(preset)"
>
${{ preset }}
</button>
</div>
<!-- Custom Amount -->
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">Custom Amount</label>
<div class="flex items-center gap-2">
<span class="text-lg font-semibold text-foreground">$</span>
<input
v-model.number="topupAmount"
type="number"
placeholder="Enter amount"
class="flex-1 px-3 py-2 bg-surface border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
min="1"
step="1"
/>
</div>
</div>
<!-- Info -->
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
<p>Minimum top-up amount is $1. Funds will be added to your wallet immediately after payment.</p>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 px-6 py-4 border-t border-border">
<button
class="px-4 py-2 text-sm font-medium text-foreground/70 hover:text-foreground transition-colors"
@click="topupDialogVisible = false"
:disabled="topupLoading"
>
Cancel
</button>
<button
class="px-4 py-2 bg-primary text-primary-foreground text-sm font-medium rounded-md hover:bg-primary/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@click="processTopup"
:disabled="!topupAmount || topupAmount < 1 || topupLoading"
>
{{ topupLoading ? 'Processing...' : 'Proceed to Payment' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</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) translateY(-10px);
}
</style>

View File

@@ -0,0 +1,351 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import ToggleSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import InputNumber from 'primevue/inputnumber';
import Dialog from 'primevue/dialog';
const toast = useToast();
const confirm = useConfirm();
// VAST Templates
interface VastTemplate {
id: string;
name: string;
vastUrl: string;
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll';
duration?: number;
enabled: boolean;
createdAt: string;
}
const templates = ref<VastTemplate[]>([
{
id: '1',
name: 'Main Pre-roll Ad',
vastUrl: 'https://ads.example.com/vast/pre-roll.xml',
adFormat: 'pre-roll',
enabled: true,
createdAt: '2024-01-10',
},
{
id: '2',
name: 'Mid-roll Ad Break',
vastUrl: 'https://ads.example.com/vast/mid-roll.xml',
adFormat: 'mid-roll',
duration: 30,
enabled: false,
createdAt: '2024-02-15',
},
]);
const showAddDialog = ref(false);
const editingTemplate = ref<VastTemplate | null>(null);
const formData = ref({
name: '',
vastUrl: '',
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
duration: undefined as number | undefined,
});
const resetForm = () => {
formData.value = {
name: '',
vastUrl: '',
adFormat: 'pre-roll',
duration: undefined,
};
editingTemplate.value = null;
};
const openAddDialog = () => {
resetForm();
showAddDialog.value = true;
};
const openEditDialog = (template: VastTemplate) => {
formData.value = {
name: template.name,
vastUrl: template.vastUrl,
adFormat: template.adFormat,
duration: template.duration,
};
editingTemplate.value = template;
showAddDialog.value = true;
};
const handleSave = () => {
if (!formData.value.name.trim()) {
toast.add({ severity: 'error', summary: 'Name Required', detail: 'Please enter a template name.', life: 3000 });
return;
}
if (!formData.value.vastUrl.trim()) {
toast.add({ severity: 'error', summary: 'VAST URL Required', detail: 'Please enter the VAST tag URL.', life: 3000 });
return;
}
try {
new URL(formData.value.vastUrl);
} catch {
toast.add({ severity: 'error', summary: 'Invalid URL', detail: 'Please enter a valid URL.', life: 3000 });
return;
}
if (formData.value.adFormat === 'mid-roll' && !formData.value.duration) {
toast.add({ severity: 'error', summary: 'Duration Required', detail: 'Mid-roll ads require a duration/interval.', life: 3000 });
return;
}
if (editingTemplate.value) {
const index = templates.value.findIndex(t => t.id === editingTemplate.value!.id);
if (index !== -1) {
templates.value[index] = { ...templates.value[index], ...formData.value };
}
toast.add({ severity: 'success', summary: 'Template Updated', detail: 'VAST template has been updated.', life: 3000 });
} else {
templates.value.push({
id: Math.random().toString(36).substring(2, 9),
...formData.value,
enabled: true,
createdAt: new Date().toISOString().split('T')[0],
});
toast.add({ severity: 'success', summary: 'Template Created', detail: 'VAST template has been created.', life: 3000 });
}
showAddDialog.value = false;
resetForm();
};
const handleToggle = (template: VastTemplate) => {
template.enabled = !template.enabled;
toast.add({
severity: 'info',
summary: template.enabled ? 'Template Enabled' : 'Template Disabled',
detail: `${template.name} has been ${template.enabled ? 'enabled' : 'disabled'}.`,
life: 2000
});
};
const handleDelete = (template: VastTemplate) => {
confirm.require({
message: `Are you sure you want to delete "${template.name}"?`,
header: 'Delete Template',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: () => {
const index = templates.value.findIndex(t => t.id === template.id);
if (index !== -1) templates.value.splice(index, 1);
toast.add({ severity: 'info', summary: 'Template Deleted', detail: 'VAST template has been removed.', life: 3000 });
}
});
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.add({ severity: 'success', summary: 'Copied', detail: 'URL copied to clipboard.', life: 2000 });
};
const getAdFormatLabel = (format: string) => {
const labels: Record<string, string> = {
'pre-roll': 'Pre-roll',
'mid-roll': 'Mid-roll',
'post-roll': 'Post-roll',
};
return labels[format] || format;
};
const getAdFormatColor = (format: string) => {
const colors: Record<string, string> = {
'pre-roll': 'bg-blue-500/10 text-blue-500',
'mid-roll': 'bg-yellow-500/10 text-yellow-500',
'post-roll': 'bg-purple-500/10 text-purple-500',
};
return colors[format] || 'bg-gray-500/10 text-gray-500';
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Ads & VAST</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Create and manage VAST ad templates for your videos.
</p>
</div>
<Button
label="Create Template"
icon="pi pi-plus"
size="small"
@click="openAddDialog"
class="press-animated"
/>
</div>
<!-- Info Banner -->
<div class="px-6 py-3 bg-info/5 border-b border-info/20">
<div class="flex items-start gap-2">
<i class="pi pi-info-circle text-info text-sm mt-0.5"></i>
<div class="text-xs text-foreground/70">
VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.
</div>
</div>
</div>
<!-- Templates Table -->
<div class="border-b border-border">
<table class="w-full">
<thead class="bg-muted/30">
<tr>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Template</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Format</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">VAST URL</th>
<th class="text-center text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Status</th>
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr
v-for="template in templates"
:key="template.id"
class="hover:bg-muted/30 transition-all"
>
<td class="px-6 py-3">
<div>
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
<p class="text-xs text-foreground/50 mt-0.5">Created {{ template.createdAt }}</p>
</div>
</td>
<td class="px-6 py-3">
<span :class="['text-xs px-2 py-1 rounded-full font-medium', getAdFormatColor(template.adFormat)]">
{{ getAdFormatLabel(template.adFormat) }}
</span>
<span v-if="template.adFormat === 'mid-roll' && template.duration" class="text-xs text-foreground/50 ml-2">
({{ template.duration }}s)
</span>
</td>
<td class="px-6 py-3">
<div class="flex items-center gap-2 max-w-[200px]">
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
<Button
icon="pi pi-copy"
text
size="small"
@click="copyToClipboard(template.vastUrl)"
/>
</div>
</td>
<td class="px-6 py-3 text-center">
<ToggleSwitch
:model-value="template.enabled"
@update:model-value="handleToggle(template)"
/>
</td>
<td class="px-6 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<Button
icon="pi pi-pencil"
text
severity="secondary"
size="small"
@click="openEditDialog(template)"
/>
<Button
icon="pi pi-trash"
text
severity="danger"
size="small"
@click="handleDelete(template)"
/>
</div>
</td>
</tr>
<tr v-if="templates.length === 0">
<td colspan="5" class="px-6 py-12 text-center">
<i class="pi pi-play-circle text-3xl text-foreground/30 mb-3 block"></i>
<p class="text-sm text-foreground/60 mb-1">No VAST templates yet</p>
<p class="text-xs text-foreground/40">Create a template to start monetizing your videos</p>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add/Edit Dialog -->
<Dialog
v-model:visible="showAddDialog"
:header="editingTemplate ? 'Edit Template' : 'Create VAST Template'"
:modal="true"
:closable="true"
class="w-full max-w-lg"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="name" class="text-sm font-medium text-foreground">Template Name</label>
<InputText
id="name"
v-model="formData.name"
placeholder="e.g., Main Pre-roll Ad"
class="w-full"
/>
</div>
<div class="grid gap-2">
<label for="vastUrl" class="text-sm font-medium text-foreground">VAST Tag URL</label>
<InputText
id="vastUrl"
v-model="formData.vastUrl"
placeholder="https://ads.example.com/vast/tag.xml"
class="w-full"
/>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-foreground">Ad Format</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="format in ['pre-roll', 'mid-roll', 'post-roll']"
:key="format"
@click="formData.adFormat = format as any"
:class="[
'px-3 py-2 border rounded-md text-sm font-medium capitalize transition-all',
formData.adFormat === format
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-foreground/60 hover:border-primary/50'
]"
>
{{ format }}
</button>
</div>
</div>
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2">
<label for="duration" class="text-sm font-medium text-foreground">Ad Interval (seconds)</label>
<InputNumber
id="duration"
v-model="formData.duration"
placeholder="30"
:min="10"
:max="600"
class="w-full"
/>
</div>
</div>
<template #footer>
<Button label="Cancel" text @click="showAddDialog = false" />
<Button
:label="editingTemplate ? 'Update' : 'Create'"
icon="pi pi-check"
@click="handleSave"
class="press-animated"
/>
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { client, type ModelPlan } from '@/api/client';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import { computed, ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import WalletBalanceCard from '../components/WalletBalanceCard.vue';
import CurrentPlanCard from '../components/CurrentPlanCard.vue';
import UsageStatsCard from '../components/UsageStatsCard.vue';
import AvailablePlansCard from '../components/AvailablePlansCard.vue';
import PaymentHistoryCard from '../components/PaymentHistoryCard.vue';
const toast = useToast();
const auth = useAuthStore();
const { data, isPending, isLoading, refresh } = useQuery({
key: () => ['payments-and-plans'],
query: () => client.plans.plansList(),
});
const subscribing = ref<string | null>(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' },
]);
// Computed Usage (Mock if not in store)
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const uploadsUsed = ref(12);
const uploadsLimit = ref(50);
// Wallet balance (from user data or mock)
const walletBalance = computed(() => 0);
const currentPlanId = computed(() => {
if (auth.user?.plan_id) return auth.user.plan_id;
if (Array.isArray(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id;
return undefined;
});
const currentPlan = computed(() => {
if (!Array.isArray(data?.value?.data?.data.plans)) return undefined;
return data.value.data.data.plans.find(p => p.id === currentPlanId.value);
});
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
});
toast.add({
severity: 'success',
summary: 'Subscription Successful',
detail: `Successfully subscribed to ${plan.name}`,
life: 3000
});
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);
toast.add({
severity: 'error',
summary: 'Subscription Failed',
detail: err.message || 'Failed to subscribe',
life: 5000
});
} finally {
subscribing.value = null;
}
};
const handleTopup = async (amount: number) => {
try {
// Simulate API call for top-up
await new Promise(resolve => setTimeout(resolve, 1500));
toast.add({
severity: 'success',
summary: 'Top-up Successful',
detail: `$${amount} has been added to your wallet.`,
life: 3000
});
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Top-up Failed',
detail: e.message || 'Failed to process top-up.',
life: 5000
});
}
};
const handleDownloadInvoice = (item: typeof paymentHistory.value[number]) => {
toast.add({
severity: 'info',
summary: 'Downloading',
detail: `Downloading invoice #${item.invoiceId}...`,
life: 2000
});
setTimeout(() => {
toast.add({
severity: 'success',
summary: 'Downloaded',
detail: `Invoice #${item.invoiceId} downloaded successfully`,
life: 3000
});
}, 1500);
};
</script>
<template>
<div class="space-y-6">
<WalletBalanceCard
:balance="walletBalance"
@topup="handleTopup"
/>
<CurrentPlanCard
:current-plan="currentPlan"
@manage="() => {}"
/>
<UsageStatsCard
:storage-used="storageUsed"
:storage-limit="storageLimit"
:uploads-used="uploadsUsed"
:uploads-limit="uploadsLimit"
/>
<AvailablePlansCard
:plans="data?.data?.data.plans || []"
:is-loading="isLoading"
:current-plan-id="currentPlanId"
:subscribing-plan-id="subscribing"
@subscribe="subscribe"
/>
<PaymentHistoryCard
:history="paymentHistory"
@download="handleDownloadInvoice"
/>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import Button from 'primevue/button';
const toast = useToast();
const confirm = useConfirm();
const handleDeleteAccount = () => {
confirm.require({
message: 'Are you sure you want to delete your account? This action cannot be undone.',
header: 'Delete Account',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: () => {
toast.add({
severity: 'info',
summary: 'Account deletion requested',
detail: 'Your account deletion request has been submitted.',
life: 5000
});
}
});
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-danger">Danger Zone</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Irreversible and destructive actions. Be careful!
</p>
</div>
<!-- Danger Zone Content -->
<div class="p-6">
<div class="border-2 border-danger/30 rounded-md bg-danger/5">
<!-- Delete Account -->
<div class="flex items-start justify-between px-5 py-4 border-b border-danger/20">
<div>
<h3 class="text-sm font-semibold text-foreground">Delete Account</h3>
<p class="text-xs text-foreground/60 mt-1">
Permanently delete your account and all associated data.
</p>
</div>
<Button
label="Delete Account"
icon="pi pi-trash"
severity="danger"
size="small"
@click="handleDeleteAccount"
class="press-animated"
/>
</div>
<!-- Clear All Data -->
<div class="flex items-start justify-between px-5 py-4">
<div>
<h3 class="text-sm font-semibold text-foreground">Clear All Data</h3>
<p class="text-xs text-foreground/60 mt-1">
Remove all your videos, playlists, and activity history.
</p>
</div>
<Button
label="Clear Data"
icon="pi pi-eraser"
severity="danger"
size="small"
outlined
class="press-animated"
/>
</div>
</div>
<!-- Warning Banner -->
<div class="mt-4 border border-warning/30 bg-warning/5 rounded-md p-4">
<div class="flex items-start gap-2">
<i class="pi pi-exclamation-triangle text-warning text-sm mt-0.5"></i>
<div class="text-xs text-foreground/70">
<p class="font-medium text-foreground mb-1">Warning</p>
<p>
These actions are permanent and cannot be undone.
Make sure you have backed up any important data before proceeding.
</p>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,237 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import ToggleSwitch from 'primevue/toggleswitch';
import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import Dialog from 'primevue/dialog';
const toast = useToast();
const confirm = useConfirm();
// Domain whitelist for iframe embedding
const domains = ref([
{ id: '1', name: 'example.com', addedAt: '2024-01-15' },
{ id: '2', name: 'mysite.org', addedAt: '2024-02-20' },
]);
const newDomain = ref('');
const showAddDialog = ref(false);
const handleAddDomain = () => {
if (!newDomain.value.trim()) {
toast.add({
severity: 'error',
summary: 'Invalid Domain',
detail: 'Please enter a valid domain name.',
life: 3000
});
return;
}
// Check for duplicates
const exists = domains.value.some(d => d.name === newDomain.value.trim().toLowerCase());
if (exists) {
toast.add({
severity: 'error',
summary: 'Domain Already Added',
detail: 'This domain is already in your whitelist.',
life: 3000
});
return;
}
domains.value.push({
id: Math.random().toString(36).substring(2, 9),
name: newDomain.value.trim().toLowerCase(),
addedAt: new Date().toISOString().split('T')[0]
});
newDomain.value = '';
showAddDialog.value = false;
toast.add({
severity: 'success',
summary: 'Domain Added',
detail: `${newDomain.value} has been added to your whitelist.`,
life: 3000
});
};
const handleRemoveDomain = (domain: typeof domains.value[0]) => {
confirm.require({
message: `Are you sure you want to remove ${domain.name} from your whitelist? Embedded iframes from this domain will no longer work.`,
header: 'Remove Domain',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Remove',
rejectLabel: 'Cancel',
acceptClass: 'p-button-danger',
accept: () => {
const index = domains.value.findIndex(d => d.id === domain.id);
if (index !== -1) {
domains.value.splice(index, 1);
}
toast.add({
severity: 'info',
summary: 'Domain Removed',
detail: `${domain.name} has been removed from your whitelist.`,
life: 3000
});
}
});
};
const getIframeCode = () => {
return `<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>`;
};
const copyIframeCode = () => {
navigator.clipboard.writeText(getIframeCode());
toast.add({
severity: 'success',
summary: 'Copied',
detail: 'Embed code copied to clipboard.',
life: 2000
});
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Allowed Domains</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Add domains to your whitelist to allow embedding content via iframe.
</p>
</div>
<Button
label="Add Domain"
icon="pi pi-plus"
size="small"
@click="showAddDialog = true"
class="press-animated"
/>
</div>
<!-- Info Banner -->
<div class="px-6 py-3 bg-info/5 border-b border-info/20">
<div class="flex items-start gap-2">
<i class="pi pi-info-circle text-info text-sm mt-0.5"></i>
<div class="text-xs text-foreground/70">
Only domains in your whitelist can embed your content using iframe.
</div>
</div>
</div>
<!-- Domain List -->
<div class="border-b border-border">
<table class="w-full">
<thead class="bg-muted/30">
<tr>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Domain</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Added Date</th>
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr
v-for="domain in domains"
:key="domain.id"
class="hover:bg-muted/30 transition-all"
>
<td class="px-6 py-3">
<div class="flex items-center gap-2">
<i class="pi pi-globe text-foreground/40 text-sm"></i>
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
</div>
</td>
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
<td class="px-6 py-3 text-right">
<Button
icon="pi pi-trash"
text
severity="danger"
size="small"
@click="handleRemoveDomain(domain)"
/>
</td>
</tr>
<tr v-if="domains.length === 0">
<td colspan="3" class="px-6 py-12 text-center">
<i class="pi pi-globe text-3xl text-foreground/30 mb-3 block"></i>
<p class="text-sm text-foreground/60 mb-1">No domains in whitelist</p>
<p class="text-xs text-foreground/40">Add a domain to allow iframe embedding</p>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Embed Code Section -->
<div class="px-6 py-4 bg-muted/30">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-foreground">Embed Code</h4>
<Button
label="Copy Code"
icon="pi pi-copy"
size="small"
text
@click="copyIframeCode"
/>
</div>
<p class="text-xs text-foreground/60 mb-2">
Use this iframe code to embed content on your whitelisted domains.
</p>
<pre class="bg-surface border border-border rounded-md p-3 text-xs text-foreground/70 overflow-x-auto"><code>{{ getIframeCode() }}</code></pre>
</div>
<!-- Add Domain Dialog -->
<Dialog
v-model:visible="showAddDialog"
header="Add Domain to Whitelist"
:modal="true"
:closable="true"
class="w-full max-w-md"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="domain" class="text-sm font-medium text-foreground">Domain Name</label>
<InputText
id="domain"
v-model="newDomain"
placeholder="example.com"
class="w-full"
@keyup.enter="handleAddDomain"
/>
<p class="text-xs text-foreground/50">Enter domain without www or https:// (e.g., example.com)</p>
</div>
<div class="bg-warning/5 border border-warning/20 rounded-md p-3">
<div class="flex items-start gap-2">
<i class="pi pi-exclamation-triangle text-warning text-sm mt-0.5"></i>
<div class="text-xs text-foreground/70">
<p class="font-medium text-foreground mb-1">Important</p>
<p>Only add domains that you own and control.</p>
</div>
</div>
</div>
</div>
<template #footer>
<Button
label="Cancel"
text
@click="showAddDialog = false"
/>
<Button
label="Add Domain"
icon="pi pi-check"
@click="handleAddDomain"
class="press-animated"
/>
</template>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ref } from 'vue';
import MailIcon from '@/components/icons/MailIcon.vue';
import BellIcon from '@/components/icons/BellIcon.vue';
import SendIcon from '@/components/icons/SendIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
const notificationSettings = ref({
email: true,
push: true,
marketing: false,
telegram: false,
});
const notificationTypes = [
{
key: 'email' as const,
title: 'Email Notifications',
description: 'Receive updates and alerts via email',
icon: MailIcon,
bgColor: 'bg-primary/10',
iconColor: 'text-primary',
},
{
key: 'push' as const,
title: 'Push Notifications',
description: 'Get instant alerts in your browser',
icon: BellIcon,
bgColor: 'bg-accent/10',
iconColor: 'text-accent',
},
{
key: 'marketing' as const,
title: 'Marketing Emails',
description: 'Receive promotions and product updates',
icon: SendIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
{
key: 'telegram' as const,
title: 'Telegram Notifications',
description: 'Receive updates via Telegram',
icon: TelegramIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
];
defineEmits<{
save: [];
}>();
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Notifications</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Choose how you want to receive notifications and updates.
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<div
v-for="type in notificationTypes"
:key="type.key"
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all"
>
<div class="flex items-center gap-4">
<div
:class="`:uno: w-10 h-10 rounded-md flex items-center justify-center shrink-0 ${type.bgColor}`"
>
<component :is="type.icon" :class="`${type.iconColor} w-5 h-5`" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ type.title }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ type.description }}</p>
</div>
</div>
<ToggleSwitch v-model="notificationSettings[type.key]" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { ref } from 'vue';
import PlayIcon from '@/components/icons/PlayIcon.vue';
import RepeatIcon from '@/components/icons/RepeatIcon.vue';
import VolumeOffIcon from '@/components/icons/VolumeOffIcon.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
import ImageIcon from '@/components/icons/ImageIcon.vue';
import WifiIcon from '@/components/icons/WifiIcon.vue';
import MonitorIcon from '@/components/icons/MonitorIcon.vue';
const playerSettings = ref({
autoplay: true,
loop: false,
muted: false,
showControls: true,
pip: true,
airplay: true,
Chromecast: false,
});
defineEmits<{
save: [];
}>();
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Player Settings</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Configure default video player behavior and features.
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<div
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all"
>
<div class="flex items-center gap-4">
<div
class=":uno: w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0"
>
<PlayIcon class="text-primary w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Autoplay</p>
<p class="text-xs text-foreground/60 mt-0.5">
Automatically start videos when loaded
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.autoplay" />
</div>
<div
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all"
>
<div class="flex items-center gap-4">
<div
class=":uno: w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0"
>
<RepeatIcon class="text-accent w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Loop</p>
<p class="text-xs text-foreground/60 mt-0.5">
Repeat video when it ends
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.loop" />
</div>
<div
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all"
>
<div class="flex items-center gap-4">
<div
class=":uno: w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0"
>
<VolumeOffIcon class="text-info w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Muted</p>
<p class="text-xs text-foreground/60 mt-0.5">
Start videos with sound muted
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.muted" />
</div>
<div
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all"
>
<div class="flex items-center gap-4">
<div
class=":uno: w-10 h-10 rounded-md bg-success/10 flex items-center justify-center shrink-0"
>
<SlidersIcon class="text-success w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Show Controls</p>
<p class="text-xs text-foreground/60 mt-0.5">
Display player controls (play, pause, volume)
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.showControls" />
</div>
<div
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all"
>
<div class="flex items-center gap-4">
<div
class=":uno: w-10 h-10 rounded-md bg-warning/10 flex items-center justify-center shrink-0"
>
<ImageIcon class="text-warning w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Picture in Picture</p>
<p class="text-xs text-foreground/60 mt-0.5">
Enable Picture-in-Picture mode
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.pip" />
</div>
<div
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all"
>
<div class="flex items-center gap-4">
<div
class=":uno: w-10 h-10 rounded-md bg-secondary/10 flex items-center justify-center shrink-0"
>
<WifiIcon class="text-secondary w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">AirPlay</p>
<p class="text-xs text-foreground/60 mt-0.5">
Allow streaming to Apple devices via AirPlay
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.airplay" />
</div>
<div
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all"
>
<div class="flex items-center gap-4">
<div
class=":uno: w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0"
>
<MonitorIcon class="text-info w-5 h-5" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Chromecast</p>
<p class="text-xs text-foreground/60 mt-0.5">
Allow casting to Chromecast devices
</p>
</div>
</div>
<ToggleSwitch v-model="playerSettings.Chromecast" />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,262 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useToast } from 'primevue/usetoast';
import { ref } from 'vue';
import ProfileInformationCard from '../components/ProfileInformationCard.vue';
import SecuritySettingsCard from '../components/SecuritySettingsCard.vue';
import ConnectedAccountsCard from '../components/ConnectedAccountsCard.vue';
const auth = useAuthStore();
const toast = useToast();
// Form state
const editing = ref(false);
const username = ref('');
const email = ref('');
const saving = ref(false);
// 2FA state
const twoFactorEnabled = ref(false);
const twoFactorDialogVisible = ref(false);
// Connected accounts state
const emailConnected = ref(true);
const telegramConnected = ref(false);
const telegramUsername = ref('');
// Change password state
const changePasswordDialogVisible = ref(false);
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const changePasswordLoading = ref(false);
const changePasswordError = ref('');
// Initialize form values
const initForm = () => {
username.value = auth.user?.username || '';
email.value = auth.user?.email || '';
emailConnected.value = !!auth.user?.email;
};
// Start editing
const startEdit = () => {
initForm();
editing.value = true;
};
// Cancel edit
const cancelEdit = () => {
editing.value = false;
};
// Save profile
const saveProfile = async () => {
saving.value = true;
try {
await auth.updateProfile({ username: username.value, email: email.value });
toast.add({
severity: 'success',
summary: 'Profile Updated',
detail: 'Your profile has been updated successfully.',
life: 3000
});
editing.value = false;
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Update Failed',
detail: e.message || 'Failed to update profile.',
life: 5000
});
} finally {
saving.value = false;
}
};
// Change password handler
const openChangePassword = () => {
changePasswordDialogVisible.value = true;
changePasswordError.value = '';
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
};
const changePassword = async () => {
changePasswordError.value = '';
if (newPassword.value !== confirmPassword.value) {
changePasswordError.value = 'Passwords do not match';
return;
}
if (newPassword.value.length < 6) {
changePasswordError.value = 'Password must be at least 6 characters';
return;
}
changePasswordLoading.value = true;
try {
await auth.changePassword(currentPassword.value, newPassword.value);
changePasswordDialogVisible.value = false;
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
toast.add({
severity: 'success',
summary: 'Password Changed',
detail: 'Your password has been changed successfully.',
life: 3000
});
} catch (e: any) {
changePasswordError.value = e.message || 'Failed to change password';
} finally {
changePasswordLoading.value = false;
}
};
// Toggle 2FA
const toggleTwoFactor = async () => {
if (!twoFactorEnabled.value) {
// Enable 2FA - generate secret and QR code
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorDialogVisible.value = true;
} catch (e) {
toast.add({
severity: 'error',
summary: 'Enable 2FA Failed',
detail: 'Failed to enable two-factor authentication.',
life: 5000
});
twoFactorEnabled.value = false;
}
} else {
// Disable 2FA
try {
await new Promise(resolve => setTimeout(resolve, 500));
toast.add({
severity: 'success',
summary: '2FA Disabled',
detail: 'Two-factor authentication has been disabled.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Disable 2FA Failed',
detail: 'Failed to disable two-factor authentication.',
life: 5000
});
twoFactorEnabled.value = true;
}
}
};
// Confirm 2FA setup
const confirmTwoFactor = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorEnabled.value = true;
toast.add({
severity: 'success',
summary: '2FA Enabled',
detail: 'Two-factor authentication has been enabled successfully.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Enable 2FA Failed',
detail: 'Invalid verification code. Please try again.',
life: 5000
});
}
};
// Connect Telegram
const connectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 1000));
telegramConnected.value = true;
telegramUsername.value = '@telegram_user';
toast.add({
severity: 'success',
summary: 'Telegram Connected',
detail: `Connected to ${telegramUsername.value}`,
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Connection Failed',
detail: 'Failed to connect Telegram account.',
life: 5000
});
}
};
// Disconnect Telegram
const disconnectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
telegramConnected.value = false;
telegramUsername.value = '';
toast.add({
severity: 'info',
summary: 'Telegram Disconnected',
detail: 'Your Telegram account has been disconnected.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Disconnect Failed',
detail: 'Failed to disconnect Telegram account.',
life: 5000
});
}
};
</script>
<template>
<div class="space-y-6">
<SecuritySettingsCard
v-model:two-factor-enabled="twoFactorEnabled"
:change-password-error="changePasswordError"
:change-password-loading="changePasswordLoading"
:current-password="currentPassword"
:new-password="newPassword"
:confirm-password="confirmPassword"
@toggle-2fa="toggleTwoFactor"
@change-password="openChangePassword"
@close-password-dialog="changePasswordDialogVisible = false"
@close-2fa-dialog="twoFactorDialogVisible = false"
@confirm-2fa="confirmTwoFactor"
@update:current-password="currentPassword = $event"
@update:new-password="newPassword = $event"
@update:confirm-password="confirmPassword = $event"
/>
<ConnectedAccountsCard
:dialog-visible="changePasswordDialogVisible"
@update:dialog-visible="changePasswordDialogVisible = $event"
:error="changePasswordError"
:loading="changePasswordLoading"
:current-password="currentPassword"
:new-password="newPassword"
:confirm-password="confirmPassword"
:email-connected="emailConnected"
:telegram-connected="telegramConnected"
:telegram-username="telegramUsername"
@close="changePasswordDialogVisible = false"
@change-password="changePassword"
@connect-telegram="connectTelegram"
@disconnect-telegram="disconnectTelegram"
@update:current-password="currentPassword = $event"
@update:new-password="newPassword = $event"
@update:confirm-password="confirmPassword = $event"
/>
</div>
</template>