diff --git a/CLAUDE.md b/CLAUDE.md index 125a589..acd2e0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,197 +2,82 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Project Overview +## Project overview -Holistream is a Vue 3 streaming application with Server-Side Rendering (SSR) deployed on Cloudflare Workers. It provides video upload, management, and streaming capabilities. +`stream-ui` is a Vue 3 SSR frontend deployed on Cloudflare Workers. It uses Hono as the Worker server layer and a custom Vite SSR setup rather than Nuxt. -## Technology Stack +## Common commands -- **Framework**: Vue 3 with JSX/TSX support -- **Router**: Vue Router 5 with SSR-aware history -- **Server**: Hono framework on Cloudflare Workers -- **Build Tool**: Vite 7 with custom SSR plugin -- **Styling**: UnoCSS (Tailwind-like utility-first CSS) -- **UI Components**: PrimeVue 4 with Aura theme -- **State Management**: Pinia + Pinia Colada for server state -- **HTTP Client**: Auto-generated from OpenAPI spec via swagger-typescript-api -- **Package Manager**: Bun - -## Common Commands +Run all commands from `stream-ui/`. ```bash -# Development server with hot reload -bun dev +# Install dependencies +bun install -# Production build (client + worker) +# Start local dev server +bun run dev + +# Build client + worker bundles bun run build # Preview production build locally -bun preview +bun run preview # Deploy to Cloudflare Workers bun run deploy -# Generate TypeScript types from Wrangler config +# Regenerate Cloudflare binding types from Wrangler config bun run cf-typegen -# View Cloudflare Worker logs +# Tail Cloudflare Worker logs bun run tail ``` -**Note**: The project uses Bun as the package manager. If using npm/yarn, replace `bun` with `npm run` or `yarn`. +Notes: +- This project uses Bun (`bun.lock` is present). +- There is currently no configured `test` script. +- There is currently no configured `lint` script. ## Architecture -### SSR Architecture +### SSR entrypoints +- `src/index.tsx`: Hono Worker entry; registers middleware, proxy routes, merge/display/manifest routes, then SSR routes +- `src/main.ts`: shared app factory for SSR and client hydration +- `src/client.ts`: client-side hydration entry +- `ssrPlugin.ts`: custom Vite SSR plugin that builds the client first, injects the Vite manifest, and swaps environment-specific modules -The app uses a custom SSR setup (`ssrPlugin.ts`) that: -- Builds the client bundle FIRST, then the Worker bundle -- Injects the Vite manifest into the server build for asset rendering -- Uses environment-based module resolution for `httpClientAdapter` and `liteMqtt` +### Routing and app structure +- Routes live in `src/routes/index.ts`. +- Routing is SSR-aware: `createMemoryHistory()` on the server and `createWebHistory()` in the browser. +- The app is split into: + - public pages + - auth pages + - protected dashboard/settings pages +- Current protected areas include `videos`, `notification`, and `settings/*` routes. -Entry points: -- **Server**: `src/index.tsx` - Hono app that renders Vue SSR stream -- **Client**: `src/client.ts` - Hydrates the SSR-rendered app +### State and hydration +- Pinia is used for app state. +- `@pinia/colada` is used for server-state/query hydration. +- SSR serializes Pinia state into `$p` and query cache into `$colada`; `src/client.ts` restores both during hydration. +- `src/stores/auth.ts` owns session state and route guards depend on `auth.user`. -### Module Aliases +### API integration +- `src/api/client.ts` is generated by `swagger-typescript-api`; do not hand-edit generated sections. +- API access should go through the generated client and `@httpClientAdapter`, not raw `fetch`. +- `src/api/httpClientAdapter.server.ts` handles SSR-side API calls by forwarding request headers/cookies and proxying frontend `/r/*` requests to `https://api.pipic.fun`. +- `src/api/httpClientAdapter.client.ts` is the browser-side adapter. -- `@/` → `src/` -- `@httpClientAdapter` → `src/api/httpClientAdapter.server.ts` (SSR) or `.client.ts` (browser) -- `@liteMqtt` → `src/lib/liteMqtt.server.ts` (SSR) or `.ts` (browser) +### Notable flows +- `src/stores/auth.ts` initializes the logged-in user from `/me` and opens an MQTT connection after login. +- `src/composables/useUploadQueue.ts` implements the custom upload queue: + - 90MB chunks + - max 3 parallel uploads + - max 3 retries + - max 5 queued items +- Styling uses UnoCSS (`uno.config.ts`). -### State Management Pattern +## Important notes -Uses **Pinia Colada** for server state with SSR hydration: -- Queries are fetched server-side and serialized to `window.__APP_DATA__` -- Client hydrates the query cache on startup via `hydrateQueryCache()` -- Pinia state is similarly serialized and restored via `PiniaSharedState` plugin - -### API Client Architecture - -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 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 - -Routes are defined in `src/routes/index.ts` with three main layouts: -1. **Public** (`/`): Landing page, terms, privacy -2. **Auth** (`/login`, `/sign-up`, `/forgot`): Authentication pages (redirects if logged in) -3. **Dashboard**: Protected routes requiring auth - - `/overview` - Main dashboard - - `/upload` - Video upload - - `/video` - Video list - - `/video/:id` - Video detail/edit - - `/payments-and-plans` - Billing - - `/notification`, `/profile` - User settings - -Route meta supports `@unhead/vue` for SEO: `meta: { head: { title, meta: [...] } }` - -### Styling System (UnoCSS) - -Configuration in `uno.config.ts`: -- **Presets**: Wind4 (Tailwind), Typography, Attributify, Bootstrap buttons -- **Custom colors**: `primary` (#14a74b), `accent`, `secondary` (#fd7906), `success`, `info`, `warning`, `danger` -- **Shortcuts**: `press-animated` for button press effects -- **Transformers**: `transformerCompileClass` (prefix: `_`), `transformerVariantGroup` - -Use `cn()` from `src/lib/utils.ts` for conditional class merging (clsx + tailwind-merge). - -### Component Auto-Import - -Components in `src/components/` are auto-imported via `unplugin-vue-components`: -- PrimeVue components resolved via `PrimeVueResolver` -- Vue/Pinia/Vue Router APIs auto-imported via `unplugin-auto-import` - -### Auth Flow - -- `useAuthStore` manages auth state with cookie-based sessions -- `init()` called on every request to fetch current user via `/me` endpoint -- `beforeEach` router guard redirects unauthenticated users from protected routes -- MQTT client connects on user login for real-time notifications - -### File Upload Architecture - -Upload queue (`src/composables/useUploadQueue.ts`): -- Supports both local files and remote URLs -- 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 - -- TypeScript strict mode enabled -- `CloudflareBindings` interface for environment variables (generated via `cf-typegen`) -- API types auto-generated from backend OpenAPI spec - -### Environment Variables - -Cloudflare Worker bindings (configured in `wrangler.jsonc`): -- No explicit secrets in code - use Wrangler secrets management -- `compatibility_date`: "2025-08-03" -- `compatibility_flags`: ["nodejs_compat"] - -## Important File Locations - -| Purpose | Path | -|---------|------| -| Server entry | `src/index.tsx` | -| Client entry | `src/client.ts` | -| App factory | `src/main.ts` | -| Router config | `src/routes/index.ts` | -| API client | `src/api/client.ts` | -| Auth store | `src/stores/auth.ts` | -| SSR plugin | `ssrPlugin.ts` | -| UnoCSS config | `uno.config.ts` | -| 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 -- The `honoContext` is provided to Vue app for accessing request context in components -- 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** `` 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) +- Prefer the actual current code over older documentation when they conflict. +- The previous version of this file contained stale route and dependency details; verify against `src/routes/index.ts` and `package.json` before assuming old pages or libraries still exist. +- Any frontend change that affects API contracts should be checked against the backend repo (`../stream.api`) as well. diff --git a/golang.tar.gz b/golang.tar.gz new file mode 100644 index 0000000..1738f41 Binary files /dev/null and b/golang.tar.gz differ diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 2743f1d..031f2c9 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -554,7 +554,7 @@ }, "overview": { "welcome": { - "title": "Welcome back, {name}! 👋", + "title": "Hello, {{name}}", "subtitle": "Here's what's happening with your content today." }, "stats": { diff --git a/public/locales/vi/translation.json b/public/locales/vi/translation.json index e89386c..f001cea 100644 --- a/public/locales/vi/translation.json +++ b/public/locales/vi/translation.json @@ -554,7 +554,7 @@ }, "overview": { "welcome": { - "title": "Chào mừng trở lại, {name}! 👋", + "title": "Xin chào, {{name}}", "subtitle": "Đây là tình hình nội dung của bạn hôm nay." }, "stats": { @@ -640,7 +640,7 @@ }, "filters": { "searchPlaceholder": "Tìm kiếm video...", - "rangeOfTotal": "{first}–{last} / {total}", + "rangeOfTotal": "{{first}}–{{last}} / {{total}}", "previousPageAria": "Trang trước", "nextPageAria": "Trang sau", "allStatus": "Tất cả trạng thái", @@ -929,7 +929,7 @@ "description": "Nội dung được phân phối từ hơn 200 PoP trên toàn thế giới. Tự động chọn vùng để có độ trễ thấp nhất cho mọi người xem." }, "live": { - "title": "API livestream", + "title": "Live Streaming API", "description": "Mở rộng tới hàng triệu người xem đồng thời với độ trễ cực thấp. Hỗ trợ RTMP ingest và HLS playback sẵn có.", "status": "Trạng thái trực tiếp", "onAir": "Đang phát", diff --git a/src/api/httpClientAdapter.server.ts b/src/api/httpClientAdapter.server.ts index 86c28ad..40767e1 100644 --- a/src/api/httpClientAdapter.server.ts +++ b/src/api/httpClientAdapter.server.ts @@ -1,31 +1,125 @@ -import { tryGetContext } from "hono/context-storage"; -export const baseAPIURL = "https://api.pipic.fun"; -export const customFetch = (url: string, options: RequestInit) => { - options.credentials = "include"; +import { tryGetContext } from 'hono/context-storage'; + +// export const baseAPIURL = 'https://api.pipic.fun'; +export const baseAPIURL = 'http://localhost:8080'; + +type RequestOptions = RequestInit | { raw: Request }; + +const isRequest = (input: URL | RequestInfo): input is Request => + typeof Request !== 'undefined' && input instanceof Request; + +const isRequestLikeOptions = (options: RequestOptions): options is { raw: Request } => + typeof options === 'object' && options !== null && 'raw' in options && options.raw instanceof Request; + +const resolveInputUrl = (input: URL | RequestInfo, currentRequestUrl: string) => { + if (input instanceof URL) return new URL(input.toString()); + if (isRequest(input)) return new URL(input.url); + + const baseUrl = new URL(currentRequestUrl); + baseUrl.pathname = '/'; + baseUrl.search = ''; + baseUrl.hash = ''; + + return new URL(input, baseUrl); +}; + +const resolveApiUrl = (input: URL | RequestInfo, currentRequestUrl: string) => { + const inputUrl = resolveInputUrl(input, currentRequestUrl); + const apiUrl = new URL(baseAPIURL); + + apiUrl.pathname = inputUrl.pathname.replace(/^\/?r(?=\/|$)/, '') || '/'; + apiUrl.search = inputUrl.search; + apiUrl.hash = inputUrl.hash; + + return apiUrl; +}; + +const getOptionHeaders = (options: RequestOptions) => + isRequestLikeOptions(options) ? options.raw.headers : options.headers; + +const getOptionMethod = (options: RequestOptions) => + isRequestLikeOptions(options) ? options.raw.method : options.method; + +const getOptionBody = (options: RequestOptions) => + isRequestLikeOptions(options) ? options.raw.body : options.body; + +const getOptionSignal = (options: RequestOptions) => + isRequestLikeOptions(options) ? options.raw.signal : options.signal; + +const getOptionCredentials = (options: RequestOptions) => + isRequestLikeOptions(options) ? undefined : options.credentials; + +const mergeHeaders = (input: URL | RequestInfo, options: RequestOptions) => { + const c = tryGetContext(); + const mergedHeaders = new Headers(c?.req.raw.headers ?? undefined); + const inputHeaders = isRequest(input) ? input.headers : undefined; + const optionHeaders = getOptionHeaders(options); + + new Headers(inputHeaders).forEach((value, key) => { + mergedHeaders.set(key, value); + }); + + new Headers(optionHeaders).forEach((value, key) => { + mergedHeaders.set(key, value); + }); + + mergedHeaders.delete('host'); + mergedHeaders.delete('connection'); + mergedHeaders.delete('content-length'); + mergedHeaders.delete('transfer-encoding'); + + return mergedHeaders; +}; + +const resolveMethod = (input: URL | RequestInfo, options: RequestOptions) => { + const method = getOptionMethod(options); + if (method) return method; + if (isRequest(input)) return input.method; + return 'GET'; +}; + +const resolveBody = (input: URL | RequestInfo, options: RequestOptions, method: string) => { + if (method === 'GET' || method === 'HEAD') return undefined; + + const body = getOptionBody(options); + if (typeof body !== 'undefined') return body; + if (isRequest(input)) return input.body; + return undefined; +}; + +export const customFetch = (input: URL | RequestInfo, options: RequestOptions = {}) => { const c = tryGetContext(); if (!c) { - throw new Error("Hono context not found in SSR"); + throw new Error('Hono context not found in SSR'); } - // Merge headers properly - keep original options.headers and add request headers - const reqHeaders = new Headers(c.req.header()); - // Remove headers that shouldn't be forwarded - reqHeaders.delete("host"); - reqHeaders.delete("connection"); - const mergedHeaders: Record = {}; - reqHeaders.forEach((value, key) => { - mergedHeaders[key] = value; - }); - options.headers = { - ...mergedHeaders, - ...(options.headers as Record), + const apiUrl = resolveApiUrl(input, c.req.url); + const method = resolveMethod(input, options); + const body = resolveBody(input, options, method.toUpperCase()); + const requestOptions: RequestInit & { duplex?: 'half' } = { + ...(isRequestLikeOptions(options) ? {} : options), + method, + headers: mergeHeaders(input, options), + body, + credentials: getOptionCredentials(options) ?? 'include', + signal: getOptionSignal(options) ?? (isRequest(input) ? input.signal : undefined), }; - const apiUrl = [baseAPIURL, url.replace(/^r/, "")].join(""); - return fetch(apiUrl, options).then(async (res) => { - res.headers.getSetCookie()?.forEach((cookie) => { - c.header("Set-Cookie", cookie); - }); - return res; + if (body) { + requestOptions.duplex = 'half'; + } + + return fetch(apiUrl, requestOptions).then((response) => { + const setCookies = typeof response.headers.getSetCookie === 'function' + ? response.headers.getSetCookie() + : response.headers.get('set-cookie') + ? [response.headers.get('set-cookie')!] + : []; + + for (const cookie of setCookies) { + c.header('Set-Cookie', cookie, { append: true }); + } + + return response; }); }; diff --git a/src/components/app/AppToastHost.vue b/src/components/app/AppToastHost.vue index 5a7e627..9c9a10c 100644 --- a/src/components/app/AppToastHost.vue +++ b/src/components/app/AppToastHost.vue @@ -6,11 +6,9 @@ import XCircleIcon from '@/components/icons/XCircleIcon.vue'; import XIcon from '@/components/icons/XIcon.vue'; import { cn } from '@/lib/utils'; import { onBeforeUnmount, watchEffect } from 'vue'; -import { useTranslation } from 'i18next-vue'; import { useAppToast, type AppToastSeverity } from '@/composables/useAppToast'; const { toasts, remove } = useAppToast(); -const { t } = useTranslation(); const timers = new Map>(); @@ -93,7 +91,7 @@ onBeforeUnmount(() => { type="button" class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all" @click="dismiss(t.id)" - :aria-label="t('toast.dismissAria')" + :aria-label="$t('toast.dismissAria')" > diff --git a/src/composables/useAppConfirm.ts b/src/composables/useAppConfirm.ts index a3c7cb0..789b4a5 100644 --- a/src/composables/useAppConfirm.ts +++ b/src/composables/useAppConfirm.ts @@ -30,10 +30,9 @@ const state = reactive({ }); const requireConfirm = (options: AppConfirmOptions) => { - const i18n = getActiveI18n(); - const defaultHeader = i18n?.t('confirm.defaultHeader') ?? 'Confirm'; - const defaultAccept = i18n?.t('confirm.defaultAccept') ?? 'OK'; - const defaultReject = i18n?.t('confirm.defaultReject') ?? 'Cancel'; + const defaultHeader = 'Confirm'; + const defaultAccept = 'OK'; + const defaultReject = 'Cancel'; state.visible = true; state.loading = false; diff --git a/src/composables/useUploadQueue.ts b/src/composables/useUploadQueue.ts index 1a719d9..0a08047 100644 --- a/src/composables/useUploadQueue.ts +++ b/src/composables/useUploadQueue.ts @@ -40,8 +40,7 @@ const abortItem = (id: string) => { }; export function useUploadQueue() { - const t = (key: string, params?: Record) => - getActiveI18n()?.t(key, params) ?? key; + const t = (key: string, params?: Record) => key; const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length)); @@ -330,7 +329,7 @@ export function useUploadQueue() { const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2)); - return `${new Intl.NumberFormat(getActiveI18n()?.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US').format(value)} ${sizes[i]}`; + return `${value} ${sizes[i]}`; }; const totalSize = computed(() => { diff --git a/src/index.tsx b/src/index.tsx index 71b7eab..130bfad 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,5 @@ import { Hono } from 'hono'; -import { serveStatic } from "@hono/node-server/serve-static"; import { apiProxyMiddleware } from './server/middlewares/apiProxy'; import { setupMiddlewares } from './server/middlewares/setup'; import { registerDisplayRoutes } from './server/routes/display'; @@ -16,7 +15,6 @@ setupMiddlewares(app); // API proxy middleware (handles /r/*) app.use(apiProxyMiddleware); -app.use(serveStatic({ root: './public' })) // Routes registerWellKnownRoutes(app); registerMergeRoutes(app); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index c310951..0af8d3d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -49,13 +49,6 @@ export function getImageAspectRatio(url: string): Promise { }); } - - -// const getRuntimeLocaleTag = () => { -// const locale = getActiveI18n()?.resolvedLanguage; -// return locale === 'vi' ? 'vi-VN' : 'en-US'; -// }; - export const formatBytes = (bytes?: number) => { if (!bytes) return '0 B'; const k = 1024; @@ -80,7 +73,10 @@ export const formatDuration = (seconds?: number) => { export const formatDate = (dateString: string = "", dateOnly: boolean = false) => { if (!dateString) return ''; - return new Date(dateString).toLocaleDateString("en-US", { + const locale = typeof document !== 'undefined' + ? document.documentElement.lang === 'vi' ? 'vi-VN' : 'en-US' + : 'en-US'; + return new Date(dateString).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/main.ts b/src/main.ts index 781c05e..c0e879e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,6 @@ import { RouterView } from 'vue-router'; import I18NextVue from 'i18next-vue'; - import { withErrorBoundary } from './lib/hoc/withErrorBoundary'; import createI18nInstance from './lib/translation'; import createAppRouter from './routes'; diff --git a/src/routes/auth/login.vue b/src/routes/auth/login.vue index e62ba39..48b8e54 100644 --- a/src/routes/auth/login.vue +++ b/src/routes/auth/login.vue @@ -31,13 +31,7 @@

{{ errors.password }}

-
-
- - -
+
{{ t('auth.login.forgotPassword') }} diff --git a/src/routes/home/Home.vue b/src/routes/home/Home.vue index 1ad081c..4a0dd39 100644 --- a/src/routes/home/Home.vue +++ b/src/routes/home/Home.vue @@ -1,183 +1,21 @@ - - + + diff --git a/src/routes/home/Privacy.vue b/src/routes/home/Privacy.vue index 19b15e2..f1affb6 100644 --- a/src/routes/home/Privacy.vue +++ b/src/routes/home/Privacy.vue @@ -1,23 +1,52 @@ diff --git a/src/routes/overview/components/StatsOverview.vue b/src/routes/overview/components/StatsOverview.vue index fdc86b5..94d5844 100644 --- a/src/routes/overview/components/StatsOverview.vue +++ b/src/routes/overview/components/StatsOverview.vue @@ -1,4 +1,5 @@ diff --git a/src/routes/settings/Billing/Billing.vue b/src/routes/settings/Billing/Billing.vue new file mode 100644 index 0000000..4706634 --- /dev/null +++ b/src/routes/settings/Billing/Billing.vue @@ -0,0 +1,282 @@ + + + diff --git a/src/routes/settings/DangerZone/DangerZone.vue b/src/routes/settings/DangerZone/DangerZone.vue new file mode 100644 index 0000000..18d2267 --- /dev/null +++ b/src/routes/settings/DangerZone/DangerZone.vue @@ -0,0 +1,107 @@ + + + diff --git a/src/routes/settings/pages/DomainsDns.vue b/src/routes/settings/DomainsDns/DomainsDns.vue similarity index 83% rename from src/routes/settings/pages/DomainsDns.vue rename to src/routes/settings/DomainsDns/DomainsDns.vue index e06c4ea..3f905be 100644 --- a/src/routes/settings/pages/DomainsDns.vue +++ b/src/routes/settings/DomainsDns/DomainsDns.vue @@ -2,14 +2,14 @@ import AppButton from '@/components/app/AppButton.vue'; import AppDialog from '@/components/app/AppDialog.vue'; import AppInput from '@/components/app/AppInput.vue'; -import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue'; -import InfoIcon from '@/components/icons/InfoIcon.vue'; import LinkIcon from '@/components/icons/LinkIcon.vue'; import PlusIcon from '@/components/icons/PlusIcon.vue'; import TrashIcon from '@/components/icons/TrashIcon.vue'; import { useAppConfirm } from '@/composables/useAppConfirm'; import { useAppToast } from '@/composables/useAppToast'; +import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue'; +import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue'; import { computed, ref } from 'vue'; import { useTranslation } from 'i18next-vue'; @@ -99,32 +99,25 @@ const copyIframeCode = () => { diff --git a/src/routes/settings/pages/NotificationSettings.vue b/src/routes/settings/NotificationSettings/NotificationSettings.vue similarity index 66% rename from src/routes/settings/pages/NotificationSettings.vue rename to src/routes/settings/NotificationSettings/NotificationSettings.vue index cfe5c74..07ab7f4 100644 --- a/src/routes/settings/pages/NotificationSettings.vue +++ b/src/routes/settings/NotificationSettings/NotificationSettings.vue @@ -7,6 +7,8 @@ import MailIcon from '@/components/icons/MailIcon.vue'; import SendIcon from '@/components/icons/SendIcon.vue'; import TelegramIcon from '@/components/icons/TelegramIcon.vue'; import { useAppToast } from '@/composables/useAppToast'; +import SettingsRow from '@/routes/settings/components/SettingsRow.vue'; +import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue'; import { computed, ref } from 'vue'; import { useTranslation } from 'i18next-vue'; @@ -81,45 +83,33 @@ const handleSave = async () => { diff --git a/src/routes/settings/pages/PlayerSettings.vue b/src/routes/settings/PlayerSettings/PlayerSettings.vue similarity index 84% rename from src/routes/settings/pages/PlayerSettings.vue rename to src/routes/settings/PlayerSettings/PlayerSettings.vue index 3cb16ef..d8cc774 100644 --- a/src/routes/settings/pages/PlayerSettings.vue +++ b/src/routes/settings/PlayerSettings/PlayerSettings.vue @@ -5,6 +5,8 @@ import AppButton from '@/components/app/AppButton.vue'; import AppSwitch from '@/components/app/AppSwitch.vue'; import CheckIcon from '@/components/icons/CheckIcon.vue'; import { useAppToast } from '@/composables/useAppToast'; +import SettingsRow from '@/routes/settings/components/SettingsRow.vue'; +import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue'; const toast = useAppToast(); const { t } = useTranslation(); @@ -24,7 +26,6 @@ const saving = ref(false); const handleSave = async () => { saving.value = true; try { - // Simulate API call await new Promise(resolve => setTimeout(resolve, 1000)); toast.add({ severity: 'success', @@ -91,47 +92,33 @@ const settingsItems = computed(() => [ diff --git a/src/routes/settings/SecurityNConnected/SecurityNConnected.vue b/src/routes/settings/SecurityNConnected/SecurityNConnected.vue new file mode 100644 index 0000000..596257b --- /dev/null +++ b/src/routes/settings/SecurityNConnected/SecurityNConnected.vue @@ -0,0 +1,517 @@ + + + diff --git a/src/routes/settings/components/ConnectedAccountsCard.vue b/src/routes/settings/components/ConnectedAccountsCard.vue deleted file mode 100644 index 759f7cb..0000000 --- a/src/routes/settings/components/ConnectedAccountsCard.vue +++ /dev/null @@ -1,185 +0,0 @@ - - - diff --git a/src/routes/settings/components/ProfileInformationCard.vue b/src/routes/settings/components/ProfileInformationCard.vue deleted file mode 100644 index db1d889..0000000 --- a/src/routes/settings/components/ProfileInformationCard.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - diff --git a/src/routes/settings/components/SecuritySettingsCard.vue b/src/routes/settings/components/SecuritySettingsCard.vue deleted file mode 100644 index 34d0235..0000000 --- a/src/routes/settings/components/SecuritySettingsCard.vue +++ /dev/null @@ -1,173 +0,0 @@ - - - diff --git a/src/routes/settings/components/SettingsNotice.vue b/src/routes/settings/components/SettingsNotice.vue new file mode 100644 index 0000000..5e0e3d3 --- /dev/null +++ b/src/routes/settings/components/SettingsNotice.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/routes/settings/components/SettingsRow.vue b/src/routes/settings/components/SettingsRow.vue new file mode 100644 index 0000000..504fa32 --- /dev/null +++ b/src/routes/settings/components/SettingsRow.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/routes/settings/components/SettingsSectionCard.vue b/src/routes/settings/components/SettingsSectionCard.vue new file mode 100644 index 0000000..0d4cae2 --- /dev/null +++ b/src/routes/settings/components/SettingsSectionCard.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/routes/settings/components/billing/BillingHistorySection.vue b/src/routes/settings/components/billing/BillingHistorySection.vue new file mode 100644 index 0000000..2f5186b --- /dev/null +++ b/src/routes/settings/components/billing/BillingHistorySection.vue @@ -0,0 +1,93 @@ + + + diff --git a/src/routes/settings/components/billing/BillingPlansSection.vue b/src/routes/settings/components/billing/BillingPlansSection.vue new file mode 100644 index 0000000..9b4a1c5 --- /dev/null +++ b/src/routes/settings/components/billing/BillingPlansSection.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/routes/settings/components/billing/BillingTopupDialog.vue b/src/routes/settings/components/billing/BillingTopupDialog.vue new file mode 100644 index 0000000..e1e5a88 --- /dev/null +++ b/src/routes/settings/components/billing/BillingTopupDialog.vue @@ -0,0 +1,105 @@ + + + diff --git a/src/routes/settings/components/billing/BillingUsageSection.vue b/src/routes/settings/components/billing/BillingUsageSection.vue new file mode 100644 index 0000000..a13bee1 --- /dev/null +++ b/src/routes/settings/components/billing/BillingUsageSection.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/routes/settings/components/billing/BillingWalletRow.vue b/src/routes/settings/components/billing/BillingWalletRow.vue new file mode 100644 index 0000000..403537a --- /dev/null +++ b/src/routes/settings/components/billing/BillingWalletRow.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/routes/settings/pages/Billing.vue b/src/routes/settings/pages/Billing.vue deleted file mode 100644 index 092da67..0000000 --- a/src/routes/settings/pages/Billing.vue +++ /dev/null @@ -1,468 +0,0 @@ - - - diff --git a/src/routes/settings/pages/DangerZone.vue b/src/routes/settings/pages/DangerZone.vue deleted file mode 100644 index c8a5082..0000000 --- a/src/routes/settings/pages/DangerZone.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - diff --git a/src/routes/settings/pages/SecurityNConnected.vue b/src/routes/settings/pages/SecurityNConnected.vue deleted file mode 100644 index 69a7593..0000000 --- a/src/routes/settings/pages/SecurityNConnected.vue +++ /dev/null @@ -1,550 +0,0 @@ - - - diff --git a/src/routes/video/components/Detail/VideoInfoHeader.vue b/src/routes/video/components/Detail/VideoInfoHeader.vue index 0abec55..b744ceb 100644 --- a/src/routes/video/components/Detail/VideoInfoHeader.vue +++ b/src/routes/video/components/Detail/VideoInfoHeader.vue @@ -3,7 +3,6 @@ import type { ModelVideo } from '@/api/client'; import { formatBytes, getStatusSeverity } from '@/lib/utils'; import { useTranslation } from 'i18next-vue'; import { computed } from 'vue'; -// import { getActiveI18n } from '@/i18n'; const props = defineProps<{ video: ModelVideo; @@ -15,7 +14,7 @@ const emit = defineEmits<{ delete: []; }>(); -const { t } = useTranslation(); +const { t, i18next } = useTranslation(); const formatFileSize = (bytes?: number): string => { if (!bytes) return '-'; @@ -36,7 +35,7 @@ const formatDuration = (seconds?: number): string => { const formatDate = (dateStr?: string): string => { if (!dateStr) return '-'; const date = new Date(dateStr); - return date.toLocaleString(getActiveI18n()?.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US', { + return date.toLocaleString(i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US', { month: 'long', day: 'numeric', year: 'numeric', diff --git a/src/server/middlewares/apiProxy.ts b/src/server/middlewares/apiProxy.ts index f8e9e5f..8933309 100644 --- a/src/server/middlewares/apiProxy.ts +++ b/src/server/middlewares/apiProxy.ts @@ -1,4 +1,4 @@ -import { baseAPIURL } from '@/api/httpClientAdapter.server'; +import { customFetch } from '@httpClientAdapter'; import type { Context, Next } from 'hono'; export async function apiProxyMiddleware(c: Context, next: Next) { @@ -7,23 +7,5 @@ export async function apiProxyMiddleware(c: Context, next: Next) { if (path !== '/r' && !path.startsWith('/r/')) { return await next(); } - - const url = new URL(c.req.url); - url.host = baseAPIURL.replace(/^https?:\/\//, ''); - url.protocol = 'https:'; - url.pathname = path.replace(/^\/r/, '') || '/'; - url.port = ''; - - const headers = new Headers(c.req.header()); - headers.delete("host"); - headers.delete("connection"); - - return fetch(url.toString(), { - method: c.req.method, - headers: headers, - body: c.req.raw.body, - // @ts-ignore - duplex: 'half', - credentials: 'include' - }); + return customFetch(c.req.url, c.req) } diff --git a/src/server/middlewares/setup.ts b/src/server/middlewares/setup.ts index 6607474..49eda93 100644 --- a/src/server/middlewares/setup.ts +++ b/src/server/middlewares/setup.ts @@ -10,17 +10,17 @@ export function setupMiddlewares(app: Hono) { fallbackLanguage: 'en', lookupCookie: 'i18next', lookupFromHeaderKey: 'accept-language', - order: ['cookie', 'header'], + order: ['cookie', 'header'], }) ,contextStorage()); - + app.use(cors(), async (c, next) => { c.set("fetch", app.request.bind(app)); - + const ua = c.req.header("User-Agent"); if (!ua) { return c.json({ error: "User-Agent header is missing" }, 400); } - + c.set("isMobile", isMobile({ ua })); await next(); }); diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 2740fd8..b81c716 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,11 +1,38 @@ -import { client, ResponseResponse, type ModelUser } from '@/api/client'; +import { client, type ModelUser, type ResponseResponse } from '@/api/client'; import { TinyMqttClient } from '@/lib/liteMqtt'; import { useTranslation } from 'i18next-vue'; import { defineStore } from 'pinia'; import { ref, watch } from 'vue'; import { useRouter } from 'vue-router'; -type ProfileUpdatePayload = { username?: string; email?: string; language?: string; locale?: string }; +type ProfileUpdatePayload = { + username?: string; + email?: string; + language?: string; + locale?: string; +}; + +type AuthResponseBody = ResponseResponse & { + data?: ModelUser | { user?: ModelUser }; +}; + +const mqttBrokerUrl = 'wss://mqtt-dashboard.com:8884/mqtt'; + +const extractUser = (body?: AuthResponseBody | null): ModelUser | null => { + const data = body?.data; + + if (!data) return null; + if (typeof data === 'object' && 'user' in data && data.user) { + return data.user; + } + + return data as ModelUser; +}; + +const getGoogleLoginPath = () => { + const basePath = client.baseUrl.startsWith('/') ? client.baseUrl : `/${client.baseUrl}`; + return `${basePath}/auth/google/login`; +}; export const useAuthStore = defineStore('auth', () => { const user = ref(null); @@ -15,94 +42,74 @@ export const useAuthStore = defineStore('auth', () => { const error = ref(null); const initialized = ref(false); - watch(user, (newUser) => { - if (import.meta.env.SSR) return; - let client: TinyMqttClient | undefined; - if (newUser?.id) { - client = new TinyMqttClient( - // 'wss://broker.emqx.io:8084/mqtt', - 'wss://mqtt-dashboard.com:8884/mqtt', - [['ecos1231231',newUser.id,'#'].join("/")], - (topic, msg) => console.log(`Tín hiệu nhận được [${topic}]:`, msg) - ); - client.connect(); - // client.auth.clearToken(); - } - else { - if(client?.disconnect) client.disconnect(); - client = undefined; - } - }, { deep: true }); + let mqttClient: TinyMqttClient | undefined; + + const clearMqttClient = () => { + mqttClient?.disconnect(); + mqttClient = undefined; + }; + + const clearState = () => { + user.value = null; + loading.value = false; + error.value = null; + initialized.value = false; + }; + + watch(() => user.value?.id, (userId) => { + if (import.meta.env.SSR) return; + + clearMqttClient(); + if (!userId) return; + + mqttClient = new TinyMqttClient( + mqttBrokerUrl, + [['ecos1231231', userId, '#'].join('/')], + (topic, message) => { + console.log(`Tín hiệu nhận được [${topic}]:`, message); + } + ); + mqttClient.connect(); + }); - watch(user, (newUser) => { - if (import.meta.env.SSR || !initialized.value || !newUser) return; - // const locale = getUserPreferredLocale(newUser); - // const activeLocale = getActiveI18n()?.resolvedLanguage; - // if (!locale || (activeLocale && normalizeLocale(activeLocale) === locale)) return; - // applyRuntimeLocale(locale); - // writeLocaleCookie(locale); - }, { deep: true }); - // Initial check for session could go here if there was a /me endpoint or token check async function init() { if (initialized.value) return; - await client.request({ - path: '/me', - method: 'GET', - format: "json", - }).then(r => r.json()).then(r => { - if (r.data) { - user.value = r.data.user as ModelUser; - // const profileLocale = getUserPreferredLocale(user.value); - // const activeLocale = getActiveI18n()?.resolvedLanguage; - // if (profileLocale && (!activeLocale || normalizeLocale(activeLocale) !== profileLocale)) { - // applyRuntimeLocale(profileLocale); - // writeLocaleCookie(profileLocale); - // } - } - }).catch(() => { }).finally(() => { - initialized.value = true; - }); - // client.request< - // ResponseResponse & { - // data?: ModelUser; - // }, - // ResponseResponse - // >({ - // path: '/me', - // method: 'GET' - // }).then(console.log) - // .finally(() => { - // initialized.value = true; - // }); - } - async function login(username: string, password: string) { - loading.value = true; - error.value = null; try { - const response = await client.auth.loginCreate({ - email: username, - password: password + const response = await client.request({ + path: '/me', + method: 'GET', + format: 'json', }); - // Expected response structure: { data: { code: 200, data: User, message: "..." } } based on typical wrapper + schema - // BUT client.ts generated code typically returns the body directly in .data property of HttpResponse - // And schema says ResponseResponse has 'data': {} - // So: response.data (HttpResponse body) -> .data (ResponseResponse payload) + const nextUser = extractUser(response.data as AuthResponseBody); + if (nextUser) { + user.value = nextUser; + } + } catch { + user.value = null; + } finally { + initialized.value = true; + } + } - const body = response.data as any; // Cast to access potential 'data' property if types are loose - console.log("body", body); - if (body && body.data) { - user.value = body.data.user; - // const profileLocale = getUserPreferredLocale(user.value); - // if (profileLocale) { - // applyRuntimeLocale(profileLocale); - // writeLocaleCookie(profileLocale); - // } - router.push('/'); - } else { + async function login(email: string, password: string) { + loading.value = true; + error.value = null; + + try { + const response = await client.auth.loginCreate({ + email, + password, + }); + const nextUser = extractUser(response.data as AuthResponseBody); + + if (!nextUser) { throw new Error(t('auth.errors.loginNoUserData')); } + + user.value = nextUser; + await router.push('/'); } catch (e: any) { console.error(e); error.value = t('auth.errors.loginFailed', { error: e.message || t('auth.errors.unknown') }); @@ -112,32 +119,22 @@ export const useAuthStore = defineStore('auth', () => { } } - async function loginWithGoogle() { - // usually this initiates a redirect loop. - // Doing it via client.request might follow redirect or return html. - // Best to just redirect the window. - window.location.href = `${client.baseUrl}/auth/google/login`; + function loginWithGoogle() { + if (typeof window === 'undefined') return; + window.location.assign(getGoogleLoginPath()); } async function register(username: string, email: string, password: string) { loading.value = true; error.value = null; + try { - const response = await client.auth.registerCreate({ + await client.auth.registerCreate({ username, email, - password + password, }); - - // Check success - const body = response.data as any; - if (response.ok) { - // Auto login or redirect to login? - // Usually register returns success, user must login. - router.push('/login'); - } else { - throw new Error(body.message || t('auth.errors.registrationFailedFallback')); - } + await router.push('/login'); } catch (e: any) { console.error(e); error.value = t('auth.errors.registrationFailed', { error: e.message || t('auth.errors.unknown') }); @@ -150,21 +147,20 @@ export const useAuthStore = defineStore('auth', () => { async function updateProfile(data: ProfileUpdatePayload) { loading.value = true; error.value = null; + try { - const response = await client.request< - ResponseResponse & { data?: ModelUser }, - ResponseResponse - >({ + const response = await client.request({ path: '/me', method: 'PUT', body: data, - format: 'json' + format: 'json', }); + const nextUser = extractUser(response.data as AuthResponseBody); - const body = response.data as any; - if (body && body.data) { - user.value = { ...(user.value ?? {}), ...body.data } as ModelUser; + if (nextUser) { + user.value = { ...(user.value ?? {}), ...nextUser } as ModelUser; } + return true; } catch (e: any) { console.error('Update profile error', e); @@ -176,34 +172,14 @@ export const useAuthStore = defineStore('auth', () => { } async function setLanguage(locale: string) { - // const normalizedLocale = normalizeLocale(locale); - // const previousLocale = resolveUserLocale(user.value); - - // applyRuntimeLocale(normalizedLocale); - // writeLocaleCookie(normalizedLocale); - const previousUser = user.value ? { ...user.value } : null; - - if (user.value) { - user.value = { - ...user.value, - // language: normalizedLocale, - // locale: normalizedLocale, - } as ModelUser; - } - if (!user.value?.id) { return { ok: true as const, fallbackOnly: true as const }; } try { - // await updateProfile({ language: normalizedLocale, locale: normalizedLocale }); + await updateProfile({ language: locale, locale }); return { ok: true as const, fallbackOnly: false as const }; } catch (e) { - // applyRuntimeLocale(previousLocale); - if (previousUser) { - user.value = previousUser as ModelUser; - } - // writeLocaleCookie(normalizedLocale); return { ok: false as const, fallbackOnly: true as const, error: e }; } } @@ -211,15 +187,16 @@ export const useAuthStore = defineStore('auth', () => { async function changePassword(currentPassword: string, newPassword: string) { loading.value = true; error.value = null; + try { await client.request({ path: '/auth/change-password', method: 'POST', body: { current_password: currentPassword, - new_password: newPassword + new_password: newPassword, }, - format: 'json' + format: 'json', }); return true; } catch (e: any) { @@ -231,6 +208,22 @@ export const useAuthStore = defineStore('auth', () => { } } + async function logout() { + loading.value = true; + error.value = null; + + try { + await client.auth.logoutCreate(); + } catch (e) { + console.error('Logout error', e); + } finally { + clearMqttClient(); + user.value = null; + loading.value = false; + await router.push('/login'); + } + } + return { user, loading, @@ -243,31 +236,10 @@ export const useAuthStore = defineStore('auth', () => { updateProfile, changePassword, setLanguage, - logout: async () => { - loading.value = true; - // const activeLocale = getActiveI18n()?.resolvedLanguage; - // const localeBeforeLogout = typeof activeLocale === 'string' ? normalizeLocale(activeLocale) : undefined; - try { - await client.auth.logoutCreate(); - user.value = null; - router.push('/login'); - } catch (e: any) { - console.error('Logout error', e); - user.value = null; - router.push('/login'); - } finally { - // if (localeBeforeLogout) { - // writeLocaleCookie(localeBeforeLogout); - // applyRuntimeLocale(localeBeforeLogout); - // } - loading.value = false; - } - }, + logout, $reset: () => { - user.value = null; - loading.value = false; - error.value = null; - initialized.value = false; - } + clearMqttClient(); + clearState(); + }, }; });