develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
19 changed files with 114 additions and 215 deletions
Showing only changes of commit 3c24da4af8 - Show all commits

View File

@@ -5,6 +5,7 @@
"": {
"name": "holistream",
"dependencies": {
"@hono/node-server": "^1.19.11",
"@pinia/colada": "^0.21.7",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
@@ -169,6 +170,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
"@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="],

View File

@@ -10,6 +10,7 @@
"tail": "wrangler tail"
},
"dependencies": {
"@hono/node-server": "^1.19.11",
"@pinia/colada": "^0.21.7",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",

View File

@@ -13,10 +13,6 @@ async function render() {
pinia.use(PiniaSharedState({ enable: true, initialize: true }));
hydrateQueryCache(queryCache, appData.$colada || {});
Object.entries(appData).forEach(([key, value]) => {
(window as any)[key] = value;
});
await router.isReady();
app.mount('body', true);
}

View File

@@ -1,5 +1,4 @@
import { computed, reactive, readonly } from 'vue';
import { getActiveI18n } from '@/i18n';
export type AppConfirmOptions = {
message: string;

View File

@@ -1,4 +1,3 @@
import { getActiveI18n } from '@/i18n';
import { computed, ref } from 'vue';
export interface QueueItem {

View File

@@ -1,19 +0,0 @@
import type { i18n as I18nInstance } from 'i18next';
import { getClientI18nInstance } from '@/lib/translation/client';
import { defaultLocale, supportedLocales, type SupportedLocale } from './constants';
export { supportedLocales, defaultLocale } from './constants';
export type { SupportedLocale } from './constants';
export { localeCookieKey } from './constants';
export const normalizeLocale = (locale?: string): SupportedLocale => {
if (!locale) return defaultLocale;
const normalized = locale.toLowerCase().split('-')[0] as SupportedLocale;
return supportedLocales.includes(normalized) ? normalized : defaultLocale;
};
export const getActiveI18n = (): I18nInstance | undefined => {
return import.meta.env.SSR ? undefined : getClientI18nInstance();
};

View File

@@ -1,5 +1,6 @@
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';
@@ -15,7 +16,7 @@ setupMiddlewares(app);
// API proxy middleware (handles /r/*)
app.use(apiProxyMiddleware);
app.use(serveStatic({ root: './public' }))
// Routes
registerWellKnownRoutes(app);
registerMergeRoutes(app);

View File

@@ -1,15 +0,0 @@
import type { i18n as I18nInstance } from 'i18next';
import { createI18nInstance, initI18nInstance } from '@/lib/translation';
let clientI18n: I18nInstance | undefined;
export const createI18nForClient = async (language?: string) => {
if (!clientI18n) {
clientI18n = createI18nInstance(false);
}
return initI18nInstance(clientI18n, language, false);
};
export const getClientI18nInstance = () => clientI18n;

View File

@@ -1,75 +1,44 @@
import i18next, { type i18n as I18nInstance } from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import I18NextHttpBackend from 'i18next-http-backend';
import i18next from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import I18NextHttpBackend, { HttpBackendOptions } from "i18next-http-backend";
const backendOptions: HttpBackendOptions = {
loadPath: 'http://localhost:5173/locales/{{lng}}/{{ns}}.json',
request: (_options, url, _payload, callback) => {
fetch(url)
.then((res) =>
res.json().then((r) => {
callback(null, {
data: JSON.stringify(r),
status: 200,
})
})
)
.catch(() => {
callback(null, {
status: 500,
data: '',
})
})
},
}
export const createI18nInstance = (lng: string) => {
console.log('Initializing i18n with language:', lng);
const i18n = i18next.createInstance();
import { defaultLocale, localeCookieKey, supportedLocales, type SupportedLocale } from '@/i18n/constants';
const runtimeNamespace = 'translation';
const normalizeLanguage = (language?: string): SupportedLocale => {
if (!language) return defaultLocale;
const normalized = language.toLowerCase().split('-')[0] as SupportedLocale;
return supportedLocales.includes(normalized) ? normalized : defaultLocale;
};
const getLoadPath = () => {
const cdnBase = import.meta.env.VITE_I18N_CDN_BASE_URL?.trim().replace(/\/+$/, '');
if (cdnBase) {
return `${cdnBase}/locales/{{lng}}/{{lng}}.json`;
}
return '/locales/{{lng}}/{{lng}}.json';
};
export const createI18nInstance = (forServer: boolean) => {
const instance = i18next.createInstance();
instance.use(I18NextHttpBackend);
if (!forServer) {
instance.use(LanguageDetector);
}
return instance;
};
export const initI18nInstance = async (
instance: I18nInstance,
language?: string,
forServer: boolean = import.meta.env.SSR,
) => {
const lng = normalizeLanguage(language);
if (!instance.isInitialized) {
await instance.init({
supportedLngs: [...supportedLocales],
fallbackLng: defaultLocale,
load: 'languageOnly',
lng,
ns: [runtimeNamespace],
defaultNS: runtimeNamespace,
fallbackNS: runtimeNamespace,
interpolation: {
escapeValue: false,
},
backend: {
loadPath: getLoadPath(),
},
...(forServer
? {}
: {
detection: {
order: ['cookie', 'navigator', 'htmlTag'],
lookupCookie: localeCookieKey,
caches: ['cookie'],
},
}),
});
return instance;
}
if (instance.resolvedLanguage !== lng) {
await instance.changeLanguage(lng);
}
return instance;
i18n
.use(I18NextHttpBackend)
.use(LanguageDetector)
.init({
lng,
supportedLngs: ["en", "vi"],
fallbackLng: "en",
defaultNS: "translation",
ns: ['translation'],
interpolation: {
escapeValue: false,
},
backend: backendOptions,
});
return i18n;
};
export default createI18nInstance;

View File

@@ -1,6 +1,5 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { getActiveI18n } from '@/i18n';
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
@@ -52,10 +51,10 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
const getRuntimeLocaleTag = () => {
const locale = getActiveI18n()?.resolvedLanguage;
return locale === 'vi' ? 'vi-VN' : 'en-US';
};
// const getRuntimeLocaleTag = () => {
// const locale = getActiveI18n()?.resolvedLanguage;
// return locale === 'vi' ? 'vi-VN' : 'en-US';
// };
export const formatBytes = (bytes?: number) => {
if (!bytes) return '0 B';
@@ -63,7 +62,8 @@ export const formatBytes = (bytes?: number) => {
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(getRuntimeLocaleTag()).format(value)} ${sizes[i]}`;
return `${value} ${sizes[i]}`;
// return `${new Intl.NumberFormat(getRuntimeLocaleTag()).format(value)} ${sizes[i]}`;
};
export const formatDuration = (seconds?: number) => {
@@ -80,7 +80,7 @@ export const formatDuration = (seconds?: number) => {
export const formatDate = (dateString: string = "", dateOnly: boolean = false) => {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString(getRuntimeLocaleTag(), {
return new Date(dateString).toLocaleDateString("en-US", {
month: 'short',
day: 'numeric',
year: 'numeric',

View File

@@ -5,13 +5,11 @@ import { createPinia } from 'pinia';
import { createSSRApp } from 'vue';
import { RouterView } from 'vue-router';
import type { i18n as I18nInstance } from 'i18next';
import I18NextVue from 'i18next-vue';
import { createI18nInstance, initI18nInstance } from '@/lib/translation';
import { createI18nForClient } from '@/lib/translation/client';
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
import createI18nInstance from './lib/translation';
import createAppRouter from './routes';
const bodyClass = ':uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen';
@@ -21,7 +19,7 @@ const getSerializedAppData = () => {
return JSON.parse(document.getElementById('__APP_DATA__')?.innerText || '{}') as Record<string, any>;
};
export async function createApp(lng: string = 'en', i18next?: I18nInstance) {
export async function createApp(lng: string = 'en') {
const pinia = createPinia();
const app = createSSRApp(withErrorBoundary(RouterView));
@@ -35,13 +33,7 @@ export async function createApp(lng: string = 'en', i18next?: I18nInstance) {
}
});
app.use(pinia);
const runtimeI18n = import.meta.env.SSR
? (i18next ?? createI18nInstance(true))
: await createI18nForClient(lng);
if (import.meta.env.SSR) {
await initI18nInstance(runtimeI18n, lng, true);
}
app.use(I18NextVue, { i18next: runtimeI18n });
app.use(I18NextVue, { i18next: createI18nInstance(lng) });
app.use(PiniaColada, {
pinia,
plugins: [
@@ -57,8 +49,6 @@ export async function createApp(lng: string = 'en', i18next?: I18nInstance) {
});
const queryCache = useQueryCache();
const router = createAppRouter();
app.use(router);
if (!import.meta.env.SSR) {
Object.entries(appData).forEach(([key, value]) => {
@@ -69,5 +59,8 @@ export async function createApp(lng: string = 'en', i18next?: I18nInstance) {
}
}
const router = createAppRouter();
app.use(router);
return { app, router, head, pinia, bodyClass, queryCache };
}

View File

@@ -172,12 +172,12 @@
</section>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { cn } from '@/lib/utils';
import { useTranslation } from 'i18next-vue';
import { computed } from 'vue';
const { t } = useTranslation();
const { t, i18next } = useTranslation();
console.log('Current locale:', i18next);
const getFeatureList = (key: string): string[] => {
const localized = t(key, { returnObjects: true });
return Array.isArray(localized) ? localized.map((item) => String(item)) : [];

View File

@@ -12,9 +12,8 @@ import UploadIcon from '@/components/icons/UploadIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import { computed, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
import { getActiveI18n } from '@/i18n';
import { computed, ref } from 'vue';
const toast = useAppToast();
const auth = useAuthStore();

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
@@ -8,19 +7,18 @@ import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import { supportedLocales, type SupportedLocale } from '@/i18n/constants';
import { normalizeLocale } from '@/i18n';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { computed, ref, watch } from 'vue';
import { supportedLocales } from '@/i18n/constants';
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
import { computed, ref, watch } from 'vue';
const auth = useAuthStore();
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useTranslation();
const selectedLanguage = ref<SupportedLocale>(normalizeLocale((auth.user as any)?.language ?? (auth.user as any)?.locale));
const languageSaving = ref(false);
const languageOptions = computed(() => supportedLocales.map((value) => ({
@@ -29,7 +27,7 @@ const languageOptions = computed(() => supportedLocales.map((value) => ({
})));
watch(() => auth.user, (nextUser) => {
selectedLanguage.value = normalizeLocale((nextUser as any)?.language ?? (nextUser as any)?.locale);
// selectedLanguage.value = normalizeLocale((nextUser as any)?.language ?? (nextUser as any)?.locale);
}, { deep: true });
// 2FA state

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import { formatBytes, getStatusSeverity } from '@/lib/utils';
import { computed } from 'vue';
import { useTranslation } from 'i18next-vue';
import { getActiveI18n } from '@/i18n';
import { computed } from 'vue';
// import { getActiveI18n } from '@/i18n';
const props = defineProps<{
video: ModelVideo;

View File

@@ -3,34 +3,18 @@ import { renderSSRHead } from '@unhead/vue/server';
import { streamText } from 'hono/streaming';
import { renderToWebStream } from 'vue/server-renderer';
import { localeCookieKey } from '@/i18n';
import { createI18nInstance, initI18nInstance } from '@/lib/translation';
import { buildBootstrapScript } from '@/lib/manifest';
import { createApp } from '@/main';
import { htmlEscape } from '@/server/utils/htmlEscape';
import { useAuthStore } from '@/stores/auth';
import type { Hono } from 'hono';
const parseCookie = (cookieHeader: string | undefined, key: string): string | undefined => {
if (!cookieHeader) return undefined;
const segments = cookieHeader.split(';');
for (const segment of segments) {
const [rawKey, ...rest] = segment.trim().split('=');
if (rawKey !== key) continue;
return decodeURIComponent(rest.join('='));
}
return undefined;
};
export function registerSSRRoutes(app: Hono) {
app.get("*", async (c) => {
const nonce = crypto.randomUUID();
const url = new URL(c.req.url);
const lang = c.get("language")
const localeFromCookie = parseCookie(c.req.header('cookie'), localeCookieKey);
const i18next = createI18nInstance(true);
await initI18nInstance(i18next, localeFromCookie ?? lang, true);
const { app: vueApp, router, head, pinia, bodyClass, queryCache } = await createApp(localeFromCookie ?? lang, i18next);
const lang = c.get("language");
const { app: vueApp, router, head, pinia, bodyClass, queryCache } = await createApp(lang);
vueApp.provide("honoContext", c);
@@ -49,7 +33,7 @@ export function registerSSRRoutes(app: Hono) {
const appStream = renderToWebStream(vueApp, ctx);
// HTML Head
await stream.write(`<!DOCTYPE html><html lang='${i18next.resolvedLanguage ?? lang}'><head>`);
await stream.write(`<!DOCTYPE html><html lang='${lang}'><head>`);
await stream.write("<base href='" + url.origin + "'/>");
// SSR Head tags
@@ -79,7 +63,7 @@ export function registerSSRRoutes(app: Hono) {
Object.assign(ctx, {
$p: pinia.state.value,
$colada: serializeQueryCache(queryCache),
$locale: i18next.resolvedLanguage ?? lang,
$locale: lang,
});
// App data script

View File

@@ -1,36 +1,16 @@
import { defineStore } from 'pinia';
import { useRouter } from 'vue-router';
import { ref, watch } from 'vue';
import { client, ResponseResponse, type ModelUser } from '@/api/client';
import { defaultLocale, localeCookieKey, type SupportedLocale } from '@/i18n/constants';
import { getActiveI18n, normalizeLocale } from '@/i18n';
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 };
const cookieMaxAge = 60 * 60 * 24 * 365;
const writeLocaleCookie = (locale: SupportedLocale) => {
if (typeof document === 'undefined') return;
document.cookie = `${localeCookieKey}=${encodeURIComponent(locale)}; path=/; max-age=${cookieMaxAge}; samesite=lax`;
};
const resolveUserLocale = (target: Partial<ModelUser> | null | undefined): SupportedLocale => {
const userLocale = (target as any)?.language ?? (target as any)?.locale;
return normalizeLocale(typeof userLocale === 'string' ? userLocale : defaultLocale);
};
const applyRuntimeLocale = (locale: SupportedLocale) => {
const i18n = getActiveI18n();
if (!i18n) return;
i18n.changeLanguage(locale);
};
export const useAuthStore = defineStore('auth', () => {
const user = ref<ModelUser | null>(null);
const router = useRouter();
const t = (key: string, params?: Record<string, unknown>) =>
getActiveI18n()?.t(key, params) ?? key;
const { t } = useTranslation();
const loading = ref(false);
const error = ref<string | null>(null);
const initialized = ref(false);
@@ -55,11 +35,13 @@ export const useAuthStore = defineStore('auth', () => {
}, { deep: true });
watch(user, (newUser) => {
if (import.meta.env.SSR) return;
const locale = resolveUserLocale(newUser);
applyRuntimeLocale(locale);
writeLocaleCookie(locale);
}, { deep: true, immediate: true });
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;
@@ -70,8 +52,12 @@ export const useAuthStore = defineStore('auth', () => {
}).then(r => r.json()).then(r => {
if (r.data) {
user.value = r.data.user as ModelUser;
const resolvedLocale = resolveUserLocale(user.value);
applyRuntimeLocale(resolvedLocale);
// const profileLocale = getUserPreferredLocale(user.value);
// const activeLocale = getActiveI18n()?.resolvedLanguage;
// if (profileLocale && (!activeLocale || normalizeLocale(activeLocale) !== profileLocale)) {
// applyRuntimeLocale(profileLocale);
// writeLocaleCookie(profileLocale);
// }
}
}).catch(() => { }).finally(() => {
initialized.value = true;
@@ -108,9 +94,11 @@ export const useAuthStore = defineStore('auth', () => {
console.log("body", body);
if (body && body.data) {
user.value = body.data.user;
const resolvedLocale = resolveUserLocale(user.value);
applyRuntimeLocale(resolvedLocale);
writeLocaleCookie(resolvedLocale);
// const profileLocale = getUserPreferredLocale(user.value);
// if (profileLocale) {
// applyRuntimeLocale(profileLocale);
// writeLocaleCookie(profileLocale);
// }
router.push('/');
} else {
throw new Error(t('auth.errors.loginNoUserData'));
@@ -188,18 +176,18 @@ export const useAuthStore = defineStore('auth', () => {
}
async function setLanguage(locale: string) {
const normalizedLocale = normalizeLocale(locale);
const previousLocale = resolveUserLocale(user.value);
const previousUser = user.value ? { ...user.value } : null;
// const normalizedLocale = normalizeLocale(locale);
// const previousLocale = resolveUserLocale(user.value);
applyRuntimeLocale(normalizedLocale);
writeLocaleCookie(normalizedLocale);
// applyRuntimeLocale(normalizedLocale);
// writeLocaleCookie(normalizedLocale);
const previousUser = user.value ? { ...user.value } : null;
if (user.value) {
user.value = {
...user.value,
language: normalizedLocale,
locale: normalizedLocale,
// language: normalizedLocale,
// locale: normalizedLocale,
} as ModelUser;
}
@@ -208,14 +196,14 @@ export const useAuthStore = defineStore('auth', () => {
}
try {
await updateProfile({ language: normalizedLocale, locale: normalizedLocale });
// await updateProfile({ language: normalizedLocale, locale: normalizedLocale });
return { ok: true as const, fallbackOnly: false as const };
} catch (e) {
applyRuntimeLocale(previousLocale);
// applyRuntimeLocale(previousLocale);
if (previousUser) {
user.value = previousUser as ModelUser;
}
writeLocaleCookie(normalizedLocale);
// writeLocaleCookie(normalizedLocale);
return { ok: false as const, fallbackOnly: true as const, error: e };
}
}
@@ -257,7 +245,8 @@ export const useAuthStore = defineStore('auth', () => {
setLanguage,
logout: async () => {
loading.value = true;
const localeBeforeLogout = resolveUserLocale(user.value);
// const activeLocale = getActiveI18n()?.resolvedLanguage;
// const localeBeforeLogout = typeof activeLocale === 'string' ? normalizeLocale(activeLocale) : undefined;
try {
await client.auth.logoutCreate();
user.value = null;
@@ -267,8 +256,10 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null;
router.push('/login');
} finally {
writeLocaleCookie(localeBeforeLogout);
applyRuntimeLocale(localeBeforeLogout);
// if (localeBeforeLogout) {
// writeLocaleCookie(localeBeforeLogout);
// applyRuntimeLocale(localeBeforeLogout);
// }
loading.value = false;
}
},