diff --git a/client-v3/.gitignore b/client-v3/.gitignore index 45ea32af..a751eb35 100644 --- a/client-v3/.gitignore +++ b/client-v3/.gitignore @@ -3,4 +3,5 @@ dist/ dist-electron/ junit/ coverage/ -*.backup \ No newline at end of file +*.backup +components.d.ts \ No newline at end of file diff --git a/client-v3/package-lock.json b/client-v3/package-lock.json index 7c597c62..5a8aaa82 100644 --- a/client-v3/package-lock.json +++ b/client-v3/package-lock.json @@ -33,6 +33,8 @@ "vue-toast-notification": "^3.1.3" }, "devDependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", "@eslint/js": "^10.0.1", "@types/lodash": "~4.17.24", "@types/node": ">=22.12.0", @@ -52,6 +54,7 @@ "sass": "1.99.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", + "unplugin-vue-components": "^32.0.0", "vite": "^8.0.12", "vitest": "^4.1.6", "vue-eslint-parser": "^10.4.0" @@ -382,7 +385,6 @@ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", @@ -395,7 +397,6 @@ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", - "optional": true, "peer": true, "dependencies": { "tslib": "^2.4.0" @@ -407,7 +408,6 @@ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { "tslib": "^2.4.0" } @@ -2079,6 +2079,7 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.34", "@vue/shared": "3.5.34" @@ -5232,6 +5233,69 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/unplugin-vue-components": { + "version": "32.0.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-32.0.0.tgz", + "integrity": "sha512-uLdccgS7mf3pv1bCCP20y/hm+u1eOjAmygVkh+Oa70MPkzgl1eQv1L0CwdHNM3gscO8/GDMGIET98Ja47CBbZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "obug": "^2.1.1", + "picomatch": "^4.0.3", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/unplugin-vue-components/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/client-v3/package.json b/client-v3/package.json index 6b59178e..58d9e380 100644 --- a/client-v3/package.json +++ b/client-v3/package.json @@ -51,6 +51,8 @@ "vue-toast-notification": "^3.1.3" }, "devDependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", "@eslint/js": "^10.0.1", "@types/lodash": "~4.17.24", "@types/node": ">=22.12.0", @@ -70,6 +72,7 @@ "sass": "1.99.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", + "unplugin-vue-components": "^32.0.0", "vite": "^8.0.12", "vitest": "^4.1.6", "vue-eslint-parser": "^10.4.0" diff --git a/client-v3/src/App.vue b/client-v3/src/App.vue index a856bde9..4a046c07 100644 --- a/client-v3/src/App.vue +++ b/client-v3/src/App.vue @@ -1,13 +1,352 @@ - + + + DigiScript + + + + + + Live + + + + Start Session + + + Stop Session + + + Reload Clients + + + Jump To Page + + + + + System Config + + + Show Config + + + + Help + About + + + {{ serverConnectionName }} + + + Switch Server + + + Login + + + {{ userStore.currentUser.username }} + + Settings + + Sign Out + + + + Connected + Disconnected + + + + + + DigiScript + + + + + + + + + + + + Welcome to DigiScript + To get started, please create an admin user! + + + + + + + + + + + + This is a required field, and must be greater than 0. + + + + + - + + + + diff --git a/client-v3/src/composables/useWebSocket.ts b/client-v3/src/composables/useWebSocket.ts new file mode 100644 index 00000000..cf7b7047 --- /dev/null +++ b/client-v3/src/composables/useWebSocket.ts @@ -0,0 +1,211 @@ +import log from 'loglevel'; +import { debounce } from 'lodash'; +import { useWebSocketStore } from '@/stores/websocket'; +import { useSystemStore } from '@/stores/system'; +import { useUserStore } from '@/stores/user'; +import { getWebSocketURL } from '@/js/platform'; +import type { WsMessage } from '@/types/api/websocket'; + +const INITIAL_RECONNECT_DELAY_MS = 1000; +const MAX_RECONNECT_DELAY_MS = 30000; + +let ws: WebSocket | null = null; +let reconnectTimer: ReturnType | null = null; +let errorCount = 0; + +function getReconnectDelay(): number { + return Math.min(INITIAL_RECONNECT_DELAY_MS * 2 ** errorCount, MAX_RECONNECT_DELAY_MS); +} + +function sendObj(data: object): void { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)); + } else { + log.warn('Attempted to send WS message but socket is not open'); + } +} + +const settingsChangedToast = debounce( + async () => { + const { useToast } = await import('vue-toast-notification'); + useToast().info('Settings synced from server'); + }, + 1000, + { leading: true, trailing: false } +); + +async function handleMessage(msg: WsMessage): Promise { + const wsStore = useWebSocketStore(); + const systemStore = useSystemStore(); + const userStore = useUserStore(); + const { default: router } = await import('@/router'); + + switch (msg.OP) { + case 'SET_UUID': { + const newUUID = msg.DATA as unknown as string; + if (wsStore.internalUUID != null) { + log.debug('Reconnecting with existing UUID:', wsStore.internalUUID); + sendObj({ OP: 'REFRESH_CLIENT', DATA: wsStore.internalUUID }); + } else { + log.debug('New connection, received UUID:', newUUID); + wsStore.$patch({ internalUUID: newUUID }); + } + wsStore.$patch({ pendingAuthentication: true }); + // Authenticate immediately if we have a token + const token = userStore.authToken; + if (token) { + sendObj({ OP: 'AUTHENTICATE', DATA: { token } }); + } + break; + } + case 'WS_AUTH_SUCCESS': + wsStore.$patch({ authenticated: true, authSucceeded: true, pendingAuthentication: false }); + errorCount = 0; + log.info('WebSocket authenticated successfully'); + // Announce as new client if applicable + sendObj({ OP: 'NEW_CLIENT', DATA: {} }); + break; + case 'WS_AUTH_ERROR': + wsStore.$patch({ authenticated: false, pendingAuthentication: false }); + log.error('WebSocket authentication error:', msg.DATA); + break; + case 'WS_TOKEN_REFRESH_SUCCESS': + log.info('WebSocket token refreshed successfully'); + break; + case 'SETTINGS_CHANGED': + await systemStore.updateSettings( + msg.DATA as Parameters[0] + ); + settingsChangedToast(); + break; + case 'START_SHOW': + if (router.currentRoute.value.path !== '/ui-new/live') { + router.push('/ui-new/live'); + } + break; + case 'STOP_SHOW': + if (router.currentRoute.value.path !== '/ui-new/') { + router.push('/ui-new/'); + } + break; + case 'RELOAD_CLIENT': + window.location.reload(); + break; + default: + log.warn(`Unknown OP received from WebSocket: ${msg.OP}`); + } + + // Dispatch named Pinia action if ACTION key is present + if (msg.ACTION) { + await dispatchAction(msg.ACTION, msg.DATA); + } +} + +async function dispatchAction(action: string, data: Record): Promise { + const userStore = useUserStore(); + const systemStore = useSystemStore(); + const wsStore = useWebSocketStore(); + + const actionMap: Record) => Promise> = { + TOKEN_REFRESH: async (d) => { + const payload = d as { DATA: { access_token: string } }; + await userStore.tokenRefreshFromServer(payload.DATA.access_token); + }, + SHOW_CHANGED: async () => { + if (userStore.currentUser != null) { + await userStore.getCurrentUser(); + await userStore.getCurrentRbac(); + } + window.location.reload(); + }, + GET_CAST_LIST: async () => { + /* handled in show store — Phase 6 */ + }, + }; + + const handler = actionMap[action]; + if (handler) { + await handler(data); + } else { + log.debug(`No handler for WS action: ${action}`); + } + + // Suppress unused variable warnings + void systemStore; + void wsStore; +} + +function connect(): void { + const wsStore = useWebSocketStore(); + + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { + return; + } + + let wsURL: string; + try { + wsURL = getWebSocketURL(); + } catch (e) { + log.error('Cannot determine WebSocket URL:', e); + return; + } + + log.debug('Connecting to WebSocket:', wsURL); + ws = new WebSocket(wsURL); + + ws.onopen = () => { + wsStore.$patch({ isConnected: true }); + if (errorCount > 0) { + import('vue-toast-notification').then(({ useToast }) => { + useToast().success( + `WebSocket reconnected after ${errorCount} attempt${errorCount > 1 ? 's' : ''}` + ); + }); + } + log.info('WebSocket connected'); + }; + + ws.onmessage = (event: MessageEvent) => { + try { + const msg: WsMessage = JSON.parse(event.data as string); + handleMessage(msg).catch((err) => log.error('Error handling WS message:', err)); + } catch (e) { + log.error('Failed to parse WS message:', e); + } + }; + + ws.onclose = () => { + wsStore.$patch({ isConnected: false, authenticated: false }); + log.info('WebSocket closed, scheduling reconnect'); + scheduleReconnect(); + }; + + ws.onerror = () => { + log.error('WebSocket error'); + errorCount++; + if (errorCount === 1) { + import('vue-toast-notification').then(({ useToast }) => { + useToast().error('WebSocket connection lost'); + }); + } + }; +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + const delay = getReconnectDelay(); + log.debug(`Reconnecting WebSocket in ${delay}ms`); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); +} + +export function useWebSocket() { + const wsStore = useWebSocketStore(); + + // Register the send function in the store so other stores can call it + wsStore.registerSend(sendObj); + + return { sendObj, connect }; +} diff --git a/client-v3/src/constants/lineTypes.ts b/client-v3/src/constants/lineTypes.ts new file mode 100644 index 00000000..daf113fe --- /dev/null +++ b/client-v3/src/constants/lineTypes.ts @@ -0,0 +1,17 @@ +/** + * Script Line Type Constants + * + * These constants match the ScriptLineType enum values from the backend + * (models/script.py). Use these instead of magic numbers throughout the codebase. + */ + +export const LINE_TYPES = { + DIALOGUE: 1, + STAGE_DIRECTION: 2, + CUE_LINE: 3, + SPACING: 4, +} as const; + +export type LineType = (typeof LINE_TYPES)[keyof typeof LINE_TYPES]; + +export default LINE_TYPES; diff --git a/client-v3/src/constants/textAlignment.ts b/client-v3/src/constants/textAlignment.ts new file mode 100644 index 00000000..7fab4f3d --- /dev/null +++ b/client-v3/src/constants/textAlignment.ts @@ -0,0 +1,16 @@ +/** + * Text alignment enum constants matching backend TextAlignment IntEnum + */ +export const TEXT_ALIGNMENT = { + LEFT: 1, + CENTER: 2, + RIGHT: 3, +} as const; + +export type TextAlignment = (typeof TEXT_ALIGNMENT)[keyof typeof TEXT_ALIGNMENT]; + +export const TEXT_ALIGNMENT_CSS: Record = { + [TEXT_ALIGNMENT.LEFT]: 'left', + [TEXT_ALIGNMENT.CENTER]: 'center', + [TEXT_ALIGNMENT.RIGHT]: 'right', +}; diff --git a/client-v3/src/js/customValidators.ts b/client-v3/src/js/customValidators.ts new file mode 100644 index 00000000..a30a944c --- /dev/null +++ b/client-v3/src/js/customValidators.ts @@ -0,0 +1,3 @@ +export const notNull = (value: unknown): boolean => value != null; +export const notNullAndGreaterThanZero = (value: unknown): boolean => + value != null && (value as number) > 0; diff --git a/client-v3/src/js/http-interceptor.ts b/client-v3/src/js/http-interceptor.ts new file mode 100644 index 00000000..63d5233a --- /dev/null +++ b/client-v3/src/js/http-interceptor.ts @@ -0,0 +1,97 @@ +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; + +export default function setupHttpInterceptor(): void { + const originalFetch = window.fetch; + + let isRefreshingToken = false; + + window.fetch = async (resource, options = {}) => { + if (typeof resource === 'string' && resource.startsWith(makeURL('/api/'))) { + // Import store inside the override function — Pinia context isn't active at module load time + const { useUserStore } = await import('@/stores/user'); + const { useToast } = await import('vue-toast-notification'); + const userStore = useUserStore(); + + const token = userStore.authToken; + const isLogoutRequest = resource.endsWith('/api/v1/auth/logout'); + const isRefreshRequest = resource.endsWith('/api/v1/auth/refresh-token'); + + const newOptions = { + ...options, + headers: { + ...options.headers, + } as Record, + }; + + if (token && !Object.keys(newOptions.headers).includes('Authorization')) { + newOptions.headers = { ...newOptions.headers, Authorization: `Bearer ${token}` }; + } + + if ( + (!options.headers || !(options.headers as Record)['Content-Type']) && + (options.method === 'POST' || options.method === 'PUT') + ) { + newOptions.headers['Content-Type'] = 'application/json'; + } + + try { + const response = await originalFetch(resource, newOptions); + + if (response.status === 401 && !isLogoutRequest) { + log.warn('Received 401 Unauthorized response'); + + if (isRefreshRequest || isRefreshingToken) { + log.warn('Token refresh failed with 401 or already refreshing, logging out'); + useToast().warning('Your session has expired. Please log in again.'); + await userStore.logout(); + return response; + } + + log.info('Attempting token refresh'); + if (token) { + try { + isRefreshingToken = true; + const refreshSuccess = await userStore.refreshToken(); + isRefreshingToken = false; + + if (refreshSuccess) { + log.info('Token refresh successful, retrying original request'); + const retriedOptions = { + ...newOptions, + headers: { + ...newOptions.headers, + Authorization: `Bearer ${userStore.authToken}`, + }, + }; + return await originalFetch(resource, retriedOptions); + } + + log.warn('Token refresh failed, logging out'); + useToast().warning('Your session has expired. Please log in again.'); + await userStore.logout(); + return response; + } catch (refreshError) { + isRefreshingToken = false; + log.error('Error during token refresh:', refreshError); + useToast().error('Authentication error - please log in again'); + await userStore.logout(); + return response; + } + } else { + log.warn('401 received with no token present'); + await userStore.logout(); + return response; + } + } + + return response; + } catch (error) { + log.error('Fetch error:', error); + throw error; + } + } + + return originalFetch(resource, options); + }; +} diff --git a/client-v3/src/js/logger.ts b/client-v3/src/js/logger.ts new file mode 100644 index 00000000..e22e6248 --- /dev/null +++ b/client-v3/src/js/logger.ts @@ -0,0 +1,128 @@ +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; +import { useUserStore } from '@/stores/user'; +import { useSystemStore } from '@/stores/system'; + +let isInitialized = false; + +const FLUSH_INTERVAL_MS = 1000; +const MAX_QUEUE_SIZE = 50; + +interface LogEntry { + level: string; + message: string; + extra: Record; +} + +const logQueue: LogEntry[] = []; +let flushTimer: ReturnType | null = null; + +function enqueueLog(level: string, message: string, extra: Record): void { + logQueue.push({ level, message, extra }); + if (logQueue.length >= MAX_QUEUE_SIZE) { + flushQueue(); + return; + } + clearTimeout(flushTimer ?? undefined); + flushTimer = setTimeout(flushQueue, FLUSH_INTERVAL_MS); +} + +// Uses fetch directly (not the intercepted version) to avoid infinite logging loops. +function flushQueue() { + clearTimeout(flushTimer ?? undefined); + flushTimer = null; + if (logQueue.length === 0) return; + + const batch = logQueue.splice(0); + + const token = useUserStore().authToken; + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) headers['Authorization'] = `Bearer ${token}`; + + fetch(makeURL('/api/v1/logs/batch'), { + method: 'POST', + headers, + body: JSON.stringify({ batch: batch }), + }).catch(() => { + // Intentionally ignore errors to prevent log flooding or infinite loops + }); +} + +export function initRemoteLogging(): void { + if (isInitialized) return; + isInitialized = true; + + const originalFactory = log.methodFactory; + + const levels: Record = { + TRACE: 0, + DEBUG: 1, + INFO: 2, + WARN: 3, + ERROR: 4, + SILENT: 5, + }; + + log.methodFactory = function (methodName, logLevel, loggerName) { + const rawMethod = originalFactory(methodName, logLevel, loggerName); + + return function (message, ...args) { + rawMethod(message, ...args); + + const systemStore = useSystemStore(); + const settings = systemStore.settings; + if (!settings || !settings.client_log_enabled) return; + + const currentLevelName = methodName.toUpperCase(); + const currentLevel = levels[currentLevelName] ?? 2; + const minLevel = levels[((settings.client_log_level as string) || 'INFO').toUpperCase()] ?? 2; + if (currentLevel < minLevel) return; + + let finalMessage = message; + if (typeof message !== 'string') { + try { + finalMessage = JSON.stringify(message); + } catch { + finalMessage = String(message); + } + } + + const extra: Record = {}; + if (args.length > 0) { + extra.args = args.map((arg) => + arg instanceof Error ? { message: arg.message, stack: arg.stack, name: arg.name } : arg + ); + } + + enqueueLog(currentLevelName, finalMessage, extra); + }; + }; + + log.setLevel(log.getLevel()); + + // Sync browser console level with user's per-account preference. + // Watched via a simple interval rather than a reactive watcher (Pinia watch requires + // component context or explicit setup; this avoids that complexity). + setInterval(() => { + const userStore = useUserStore(); + const consoleLevel = (userStore.userSettings as Record)?.console_log_level as + | string + | undefined; + if (consoleLevel) log.setLevel(consoleLevel.toLowerCase() as log.LogLevelDesc, false); + }, 5000); + + window.addEventListener('error', (event) => { + enqueueLog('ERROR', `Unhandled Error: ${event.message}`, { + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + }); + }); + + window.addEventListener('unhandledrejection', (event) => { + enqueueLog('ERROR', `Unhandled Promise Rejection: ${event.reason}`, {}); + }); + + enqueueLog('INFO', 'Remote logging initialized', {}); +} diff --git a/client-v3/src/js/platform/browser.ts b/client-v3/src/js/platform/browser.ts new file mode 100644 index 00000000..1ac2a2ea --- /dev/null +++ b/client-v3/src/js/platform/browser.ts @@ -0,0 +1,31 @@ +/** + * Browser Platform Implementation + * + * Provides URL resolution and storage for browser environments. + * Uses window.location for URL construction (existing behavior). + */ + +/** + * Get the base server URL from the current browser location + * @returns {string} Base URL (e.g., "http://localhost:8080") + */ +export function baseURL(): string { + return `${window.location.protocol}//${window.location.hostname}:${window.location.port}`; +} + +export function makeURL(path: string): string { + return `${baseURL()}${path}`; +} + +export function getVersion(): string { + return import.meta.env.VITE_APP_VERSION || '0.23.0'; +} + +export function getStorageAdapter(type = 'local'): Storage { + return type === 'session' ? window.sessionStorage : window.localStorage; +} + +export function getWebSocketURL(): string { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${window.location.hostname}:${window.location.port}/api/v1/ws`; +} diff --git a/client-v3/src/js/platform/electron.ts b/client-v3/src/js/platform/electron.ts new file mode 100644 index 00000000..0ea18f0f --- /dev/null +++ b/client-v3/src/js/platform/electron.ts @@ -0,0 +1,70 @@ +declare global { + interface Window { + electronAPI?: { + getServerURLSync?: () => string | null; + getAppVersion?: () => string; + getActiveConnection?: () => Promise; + storageGet?: (key: string) => string | null; + storageSet?: (key: string, value: string) => void; + storageDelete?: (key: string) => void; + storageClear?: () => void; + }; + } +} + +export function baseURL(): string { + if (!window.electronAPI) { + throw new Error( + 'Electron API not available. This should only be called in Electron environment.' + ); + } + + const serverURL = window.electronAPI.getServerURLSync?.() || null; + + if (!serverURL) { + throw new Error('No server URL configured. Please select a server in the connection manager.'); + } + + return serverURL; +} + +export function makeURL(path: string): string { + return `${baseURL()}${path}`; +} + +export function getVersion(): string { + if (window.electronAPI?.getAppVersion) { + return window.electronAPI.getAppVersion(); + } + return '0.23.0'; +} + +export function getStorageAdapter( + _type = 'local' +): Pick { + if (!window.electronAPI) { + throw new Error('Electron API not available'); + } + + return { + getItem(key: string): string | null { + return window.electronAPI!.storageGet?.(key) ?? null; + }, + setItem(key: string, value: string): void { + window.electronAPI!.storageSet?.(key, value); + }, + removeItem(key: string): void { + window.electronAPI!.storageDelete?.(key); + }, + clear(): void { + window.electronAPI!.storageClear?.(); + }, + }; +} + +export function getWebSocketURL(): string { + const base = baseURL(); + const url = new URL(base); + const protocol = url.protocol === 'https:' ? 'wss' : 'ws'; + return `${protocol}://${url.host}/api/v1/ws`; +} diff --git a/client-v3/src/js/platform/index.ts b/client-v3/src/js/platform/index.ts new file mode 100644 index 00000000..4598ade4 --- /dev/null +++ b/client-v3/src/js/platform/index.ts @@ -0,0 +1,15 @@ +function isElectron(): boolean { + return typeof window !== 'undefined' && window.electronAPI !== undefined; +} + +let platformModule; + +if (isElectron()) { + platformModule = await import('./electron'); +} else { + platformModule = await import('./browser'); +} + +export const { baseURL, makeURL, getVersion, getStorageAdapter, getWebSocketURL } = platformModule; + +export { isElectron }; diff --git a/client-v3/src/js/utils.ts b/client-v3/src/js/utils.ts new file mode 100644 index 00000000..522d7f83 --- /dev/null +++ b/client-v3/src/js/utils.ts @@ -0,0 +1,60 @@ +import { baseURL as platformBaseURL, makeURL as platformMakeURL } from '@/js/platform'; + +export function baseURL(): string { + return platformBaseURL(); +} + +export function makeURL(path: string): string { + return platformMakeURL(path); +} + +export function titleCase(str: string, sep = ' '): string { + const splitStr = str.toLowerCase().split(sep); + for (let i = 0; i < splitStr.length; i++) { + splitStr[i] = splitStr[i].charAt(0).toUpperCase() + splitStr[i].substring(1); + } + return splitStr.join(' '); +} + +export function randInt(min: number, max: number): number { + const minCeil = Math.ceil(min); + const maxFloor = Math.floor(max); + return Math.floor(Math.random() * (maxFloor - minCeil) + minCeil); +} + +export function msToTimerString(milliseconds: number): string { + // Adapted from https://stackoverflow.com/a/33909506 + const hours = milliseconds / (1000 * 60 * 60); + const absoluteHours = Math.floor(hours); + const h = absoluteHours > 9 ? absoluteHours : `0${absoluteHours}`; + const minutes = (hours - absoluteHours) * 60; + const absoluteMinutes = Math.floor(minutes); + const m = absoluteMinutes > 9 ? absoluteMinutes : `0${absoluteMinutes}`; + const seconds = (minutes - absoluteMinutes) * 60; + const absoluteSeconds = Math.floor(seconds); + const s = absoluteSeconds > 9 ? absoluteSeconds : `0${absoluteSeconds}`; + + return `${h}:${m}:${s}`; +} + +export function msToTimerParts(milliseconds: number): [number, number, number] { + // Adapted from https://stackoverflow.com/a/33909506 + const hours = milliseconds / (1000 * 60 * 60); + const absoluteHours = Math.floor(hours); + const minutes = (hours - absoluteHours) * 60; + const absoluteMinutes = Math.floor(minutes); + const seconds = (minutes - absoluteMinutes) * 60; + const absoluteSeconds = Math.floor(seconds); + return [absoluteHours, absoluteMinutes, absoluteSeconds]; +} + +export function formatTimerParts( + hours: number, + minutes: number, + seconds: number +): [string | number, string | number, string | number] { + const h = hours > 9 ? hours : `0${hours}`; + const m = minutes > 9 ? minutes : `0${minutes}`; + const s = seconds > 9 ? seconds : `0${seconds}`; + return [h, m, s]; +} diff --git a/client-v3/src/main.ts b/client-v3/src/main.ts index bd6e6d2e..ec5a47cf 100644 --- a/client-v3/src/main.ts +++ b/client-v3/src/main.ts @@ -4,7 +4,10 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'; import App from './App.vue'; import router from './router'; +import setupHttpInterceptor from './js/http-interceptor'; +import { initRemoteLogging } from './js/logger'; import './assets/styles/dark.scss'; +import 'bootstrap-vue-next/dist/bootstrap-vue-next.css'; const app = createApp(App); @@ -14,4 +17,7 @@ pinia.use(piniaPluginPersistedstate); app.use(pinia); app.use(router); +setupHttpInterceptor(); +initRemoteLogging(); + app.mount('#app'); diff --git a/client-v3/src/router/index.ts b/client-v3/src/router/index.ts index 9daaedf6..e4b2ede1 100644 --- a/client-v3/src/router/index.ts +++ b/client-v3/src/router/index.ts @@ -1,19 +1,263 @@ import { createRouter, createWebHistory } from 'vue-router'; +import { isElectron } from '@/js/platform'; import HomeView from '@/views/HomeView.vue'; +import NotFoundView from '@/views/NotFoundView.vue'; +import PlaceholderView from '@/views/PlaceholderView.vue'; const router = createRouter({ history: createWebHistory('/ui-new/'), routes: [ + { + path: '/electron/server-selector', + name: 'electron-server-selector', + component: () => import('@/views/electron/ServerSelector.vue'), + meta: { requiresAuth: false, isElectronOnly: true }, + }, { path: '/', name: 'home', component: HomeView, + meta: { requiresAuth: false }, + }, + { + path: '/about', + name: 'about', + component: PlaceholderView, + meta: { requiresAuth: false }, + }, + { + path: '/login', + name: 'login', + component: () => import('@/views/user/LoginView.vue'), + meta: { requiresAuth: false }, + }, + { + path: '/config', + name: 'config', + component: PlaceholderView, + meta: { requiresAuth: true, requiresAdmin: true }, + }, + { + path: '/show-config', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + children: [ + { + name: 'show-config', + path: '', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-cast', + path: 'cast', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-stage', + path: 'stage', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-characters', + path: 'characters', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-acts-scenes', + path: 'acts', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-cues', + path: 'cues', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-mics', + path: 'mics', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-script', + path: 'script', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-config-script-revisions', + path: 'script-revisions', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + { + name: 'show-sessions', + path: 'sessions', + component: PlaceholderView, + meta: { requiresAuth: true, requiresShowAccess: true }, + }, + ], + }, + { + path: '/live', + name: 'live', + component: PlaceholderView, + meta: { requiresAuth: false }, + }, + { + path: '/me', + name: 'user-settings', + component: PlaceholderView, + meta: { requiresAuth: true }, + }, + { + path: '/force-password-change', + name: 'force-password-change', + component: PlaceholderView, + meta: { requiresAuth: true, requiresPasswordChange: true }, + }, + { + path: '/help', + component: PlaceholderView, + meta: { requiresAuth: false }, + children: [ + { path: '', redirect: 'getting-started' }, + { + name: 'help-doc', + path: ':slug(.*)', + component: PlaceholderView, + meta: { requiresAuth: false }, + }, + ], + }, + { + path: '/404', + name: '404', + component: NotFoundView, + meta: { requiresAuth: false }, }, { path: '/:pathMatch(.*)*', - redirect: '/', + redirect: '/404', }, ], }); +router.beforeEach(async (to) => { + const { useSystemStore } = await import('@/stores/system'); + const { useUserStore } = await import('@/stores/user'); + const { useToast } = await import('vue-toast-notification'); + + const systemStore = useSystemStore(); + const userStore = useUserStore(); + const toast = useToast(); + + // Electron: require active connection before any page except server-selector + if (isElectron() && to.path !== '/electron/server-selector') { + try { + const activeConnection = await window.electronAPI?.getActiveConnection?.(); + if (!activeConnection) { + toast.warning('Please select a server to connect to'); + return '/electron/server-selector'; + } + } catch { + return '/electron/server-selector'; + } + } + + // Electron-only pages are inaccessible in the browser + if (to.matched.some((r) => r.meta.isElectronOnly) && !isElectron()) { + toast.error('This page is only available in the desktop app'); + return '/'; + } + + if (to.path === '/electron/server-selector') return undefined; + + // Load RBAC roles on first navigation if not already loaded + if (systemStore.rbacRoles.length === 0) { + await systemStore.getRbacRoles(); + await systemStore.getSettings(); + await userStore.getCurrentUser(); + if (userStore.currentUser) { + await userStore.getCurrentRbac(); + } + } + + const requiresAuth = to.matched.some((r) => r.meta.requiresAuth); + const requiresAdmin = to.matched.some((r) => r.meta.requiresAdmin); + const requiresShowAccess = to.matched.some((r) => r.meta.requiresShowAccess); + + // If no admin user yet, send everyone to home (which shows the create-admin UI) + if ( + systemStore.settings && + (systemStore.settings as Record).has_admin_user === false + ) { + if (to.path !== '/') { + toast.error('Please create an admin user before continuing'); + return '/'; + } + return undefined; + } + + const currentUser = userStore.currentUser; + const isAuthenticated = currentUser !== null; + + // Already logged in — don't show login page + if (to.path === '/login' && isAuthenticated) { + toast.info('You are already logged in'); + return '/'; + } + + // Require auth + if (requiresAuth && !isAuthenticated) { + toast.error('Please log in to access this page'); + return '/login'; + } + + // Force password change + const requiresPasswordChange = currentUser?.requires_password_change === true; + const isPasswordChangePage = to.path === '/force-password-change'; + + if (isAuthenticated && requiresPasswordChange && !isPasswordChangePage) { + toast.warning('You must change your password before continuing'); + return '/force-password-change'; + } + + if (isPasswordChangePage && !requiresPasswordChange) { + return '/'; + } + + // Admin-only pages + if (requiresAdmin && !systemStore.isAdminUser) { + toast.error('Admin access required'); + return '/'; + } + + // Show access + if (requiresShowAccess) { + if (!systemStore.currentShow) { + toast.error('No show is currently selected'); + return '/'; + } + if (!systemStore.hasShowAccess) { + toast.error('You do not have permission to access show configuration'); + return '/'; + } + } + + // Live page requires an active show session + if (to.path === '/live') { + // Show session check added in Phase 6 when show store is available + // For now, allow navigation (the live page itself will handle the guard) + } + + return undefined; +}); + export default router; diff --git a/client-v3/src/stores/system.ts b/client-v3/src/stores/system.ts new file mode 100644 index 00000000..8251bf00 --- /dev/null +++ b/client-v3/src/stores/system.ts @@ -0,0 +1,184 @@ +import { defineStore } from 'pinia'; +import log from 'loglevel'; +import { makeURL } from '@/js/utils'; +import type { Show } from '@/types/api/show'; +import type { SystemSettings } from '@/types/api/settings'; +import { useUserStore } from '@/stores/user'; + +interface RbacRole { + key: string; + value: number; +} + +type UserRbac = Record | null; + +function getUserRbac(): UserRbac { + return useUserStore().currentRbac; +} + +function getRbacMask(roles: RbacRole[], key: string): number { + return roles.find((x) => x.key === key)?.value ?? 0; +} + +export const useSystemStore = defineStore('system', { + state: () => ({ + settings: {} as SystemSettings | Record, + availableShows: [] as Show[], + rawSettings: {} as Record, + rbacRoles: [] as RbacRole[], + settingsCategories: {} as Record, + currentShow: null as Show | null, + }), + getters: { + isAdminUser(): boolean { + return useUserStore().currentUser?.is_admin === true; + }, + isShowEditor(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.shows) return false; + return (userRbac.shows[0][1] & getRbacMask(this.rbacRoles, 'WRITE')) !== 0; + }, + isShowReader(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.shows) return false; + return (userRbac.shows[0][1] & getRbacMask(this.rbacRoles, 'READ')) !== 0; + }, + isShowExecutor(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.shows) return false; + return (userRbac.shows[0][1] & getRbacMask(this.rbacRoles, 'EXECUTE')) !== 0; + }, + isScriptEditor(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.script) return false; + return (userRbac.script[0][1] & getRbacMask(this.rbacRoles, 'WRITE')) !== 0; + }, + isScriptReader(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.script) return false; + return (userRbac.script[0][1] & getRbacMask(this.rbacRoles, 'READ')) !== 0; + }, + isCueEditor(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.cuetypes) return false; + const writeMask = getRbacMask(this.rbacRoles, 'WRITE'); + return userRbac.cuetypes.filter((x) => (x[1] & writeMask) !== 0).length > 0; + }, + isCueReader(): boolean { + if (this.isAdminUser) return true; + if (this.rbacRoles.length === 0) return false; + const userRbac = getUserRbac(); + if (!userRbac?.cuetypes) return false; + const readMask = getRbacMask(this.rbacRoles, 'READ'); + return userRbac.cuetypes.filter((x) => (x[1] & readMask) !== 0).length > 0; + }, + isAllowedShowConfig(): boolean { + return ( + this.isAdminUser || + this.isShowEditor || + this.isShowReader || + this.isShowExecutor || + this.isScriptReader || + this.isScriptEditor || + this.isCueReader || + this.isCueEditor + ); + }, + hasShowAccess(): boolean { + if (!this.currentShow) return false; + if (this.isAdminUser) return true; + const userRbac = getUserRbac(); + if (!userRbac) return false; + + const writeMask = getRbacMask(this.rbacRoles, 'WRITE'); + const readMask = getRbacMask(this.rbacRoles, 'READ'); + const execMask = getRbacMask(this.rbacRoles, 'EXECUTE'); + + const showAllowed = + userRbac.shows?.[0] && (userRbac.shows[0][1] & (writeMask | execMask | readMask)) !== 0; + const scriptAllowed = + userRbac.script?.[0] && (userRbac.script[0][1] & (writeMask | readMask)) !== 0; + const cueTypesAllowed = + userRbac.cuetypes && + userRbac.cuetypes.filter((x) => (x[1] & (writeMask | readMask)) !== 0).length > 0; + + return !!(showAllowed || scriptAllowed || cueTypesAllowed); + }, + }, + actions: { + async getAvailableShows() { + const response = await fetch(makeURL('/api/v1/shows')); + if (response.ok) { + const data = await response.json(); + this.availableShows = data.shows; + } else { + log.error('Unable to get available shows'); + } + }, + async getRawSettings() { + const response = await fetch(makeURL('/api/v1/settings/raw')); + if (response.ok) { + this.rawSettings = await response.json(); + } else { + log.error('Unable to get raw settings'); + } + }, + async getSettings() { + const response = await fetch(makeURL('/api/v1/settings')); + if (response.ok) { + const data = await response.json(); + await this.updateSettings(data); + } else { + log.error('Unable to fetch settings'); + } + }, + async updateSettings(payload: SystemSettings) { + this.settings = payload; + await this.settingsChanged(); + }, + async settingsChanged() { + await this.getRawSettings(); + + if (this.settings.current_show) { + const response = await fetch(makeURL('/api/v1/show')); + if (response.ok) { + this.currentShow = await response.json(); + } else { + log.error('Unable to fetch current show'); + } + } else { + this.currentShow = null; + } + }, + async getRbacRoles() { + const response = await fetch(makeURL('/api/v1/rbac/roles')); + if (response.ok) { + const data = await response.json(); + this.rbacRoles = data.roles; + } else { + log.error('Unable to fetch RBAC roles'); + } + }, + async getSettingsCategories() { + const response = await fetch(makeURL('/api/v1/settings/categories')); + if (response.ok) { + const data = await response.json(); + this.settingsCategories = data.categories; + } else { + log.error('Unable to fetch settings categories'); + } + }, + }, +}); diff --git a/client-v3/src/stores/user.ts b/client-v3/src/stores/user.ts new file mode 100644 index 00000000..89b7cb5a --- /dev/null +++ b/client-v3/src/stores/user.ts @@ -0,0 +1,280 @@ +import { defineStore } from 'pinia'; +import log from 'loglevel'; +import { isEmpty } from 'lodash'; +import { makeURL } from '@/js/utils'; +import type { User, UserSettings, CueColourOverride } from '@/types/api/user'; +import type { StageDirectionStyle } from '@/types/api/script'; + +const TOKEN_KEY = 'digiscript_auth_token'; + +function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} +function setToken(t: string): void { + localStorage.setItem(TOKEN_KEY, t); +} +function clearToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +export const useUserStore = defineStore('user', { + state: () => ({ + currentUser: null as User | null, + currentRbac: null as Record | null, + users: [] as User[], + tokenRefreshInterval: null as ReturnType | null, + userSettings: {} as UserSettings | Record, + stageDirectionStyleOverrides: [] as StageDirectionStyle[], + cueColourOverrides: [] as CueColourOverride[], + }), + getters: { + authToken: (): string | null => getToken(), + isAuthenticated: (): boolean => getToken() !== null, + }, + actions: { + async login(username: string, password: string): Promise { + const { useWebSocketStore } = await import('@/stores/websocket'); + const wsStore = useWebSocketStore(); + + const response = await fetch(makeURL('/api/v1/auth/login'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + password, + session_id: wsStore.internalUUID, + }), + }); + + if (response.ok) { + const data = await response.json(); + if (data.access_token) setToken(data.access_token); + + const { useSystemStore } = await import('@/stores/system'); + await useSystemStore().getRbacRoles(); + await this.getCurrentUser(); + await this.getCurrentRbac(); + await this.getUserSettings(); + await this.setupTokenRefresh(); + + // Trigger WS authentication if the connection is waiting + wsStore.triggerAuthentication(); + + const { useToast } = await import('vue-toast-notification'); + useToast().success('Successfully logged in!'); + return true; + } + + const responseBody = await response.json(); + log.error('Unable to log in'); + const { useToast } = await import('vue-toast-notification'); + useToast().error(`Unable to log in! ${responseBody.message}.`); + return false; + }, + + async logout(): Promise { + if (this.tokenRefreshInterval) { + clearInterval(this.tokenRefreshInterval); + this.tokenRefreshInterval = null; + } + + const token = getToken(); + clearToken(); + this.currentUser = null; + this.currentRbac = null; + this.userSettings = {}; + this.stageDirectionStyleOverrides = []; + + const { useWebSocketStore } = await import('@/stores/websocket'); + useWebSocketStore().$patch({ authenticated: false, authSucceeded: false }); + + if (token) { + try { + const { useWebSocketStore: getWsStore } = await import('@/stores/websocket'); + const response = await fetch(makeURL('/api/v1/auth/logout'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ session_id: getWsStore().internalUUID }), + }); + if (!response.ok) { + log.error('Logout response was not OK, but local state was cleared'); + } + } catch (error) { + log.error('Error during logout API call:', error); + } + } + + const { useToast } = await import('vue-toast-notification'); + useToast().success('Successfully logged out!'); + + const { default: router } = await import('@/router'); + if (router.currentRoute.value.path !== '/') { + router.push('/'); + } + }, + + async refreshToken(): Promise { + if (!getToken()) return false; + const response = await fetch(makeURL('/api/v1/auth/refresh-token'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (response.ok) { + const data = await response.json(); + setToken(data.access_token); + const { useWebSocketStore } = await import('@/stores/websocket'); + useWebSocketStore().refreshWsToken(); + log.debug('Token refreshed successfully'); + return true; + } + log.error('Failed to refresh token'); + return false; + }, + + async tokenRefreshFromServer(newToken: string): Promise { + log.info('Received token refresh from server'); + if (newToken) { + setToken(newToken); + const { useWebSocketStore } = await import('@/stores/websocket'); + useWebSocketStore().refreshWsToken(); + log.info('Auth token updated from server'); + } + }, + + async getCurrentUser(): Promise { + const response = await fetch(makeURL('/api/v1/auth')); + if (response.ok) { + const user = await response.json(); + this.currentUser = isEmpty(user) ? null : user; + } else { + log.error('Unable to get current user'); + } + }, + + async getCurrentRbac(): Promise { + const response = await fetch(makeURL('/api/v1/rbac/user/roles')); + if (response.ok) { + const data = await response.json(); + this.currentRbac = data.roles; + } else { + log.error("Unable to get current user's RBAC roles"); + } + }, + + async getUsers(): Promise { + if (!this.currentUser?.is_admin) return; + const response = await fetch(makeURL('/api/v1/auth/users')); + if (response.ok) { + const data = await response.json(); + this.users = data.users; + } else { + log.error('Unable to get users'); + const { useToast } = await import('vue-toast-notification'); + useToast().error('Unable to fetch users!'); + } + }, + + async createUser(user: Record): Promise { + const response = await fetch(makeURL('/api/v1/auth/create'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(user), + }); + const { useToast } = await import('vue-toast-notification'); + if (response.ok) { + await this.getUsers(); + useToast().success('User created!'); + } else { + const body = await response.json(); + log.error('Unable to create user'); + useToast().error(`Unable to create user: ${body.message || 'Unknown error'}`); + } + }, + + async deleteUser(userId: number): Promise { + const response = await fetch(makeURL('/api/v1/auth/delete'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: userId }), + }); + const { useToast } = await import('vue-toast-notification'); + if (response.ok) { + await this.getUsers(); + useToast().success('User deleted!'); + } else { + const body = await response.json(); + log.error('Unable to delete user'); + useToast().error(`Unable to delete user: ${body.message || 'Unknown error'}`); + } + }, + + async getUserSettings(): Promise { + const response = await fetch(makeURL('/api/v1/user/settings')); + if (response.ok) { + this.userSettings = await response.json(); + } else { + log.error('Unable to fetch user settings'); + } + }, + + async setupTokenRefresh(): Promise { + if (this.tokenRefreshInterval) clearInterval(this.tokenRefreshInterval); + const refreshInterval = setInterval( + async () => { + if (getToken()) { + await this.refreshToken(); + } else { + clearInterval(refreshInterval); + this.tokenRefreshInterval = null; + } + }, + 1000 * 60 * 30 + ); + this.tokenRefreshInterval = refreshInterval; + }, + + async generateApiToken(): Promise | null> { + const response = await fetch(makeURL('/api/v1/auth/api-token/generate'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const { useToast } = await import('vue-toast-notification'); + if (response.ok) { + useToast().success('API token generated successfully!'); + return response.json(); + } + const body = await response.json(); + useToast().error(`Unable to generate API token: ${body.message || 'Unknown error'}`); + return null; + }, + + async revokeApiToken(): Promise { + const response = await fetch(makeURL('/api/v1/auth/api-token/revoke'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + const { useToast } = await import('vue-toast-notification'); + if (response.ok) { + useToast().success('API token revoked successfully!'); + return true; + } + const body = await response.json(); + useToast().error(`Unable to revoke API token: ${body.message || 'Unknown error'}`); + return false; + }, + + async getApiToken(): Promise | null> { + const response = await fetch(makeURL('/api/v1/auth/api-token')); + if (response.ok) return response.json(); + const { useToast } = await import('vue-toast-notification'); + useToast().error('Unable to get API token!'); + return null; + }, + }, +}); diff --git a/client-v3/src/stores/websocket.ts b/client-v3/src/stores/websocket.ts new file mode 100644 index 00000000..9d66c724 --- /dev/null +++ b/client-v3/src/stores/websocket.ts @@ -0,0 +1,42 @@ +import { defineStore } from 'pinia'; +import log from 'loglevel'; + +export const useWebSocketStore = defineStore('websocket', { + state: () => ({ + isConnected: false, + authenticated: false, + authSucceeded: false, + pendingAuthentication: false, + internalUUID: null as string | null, + reconnectAttempts: 0, + // Registered by the useWebSocket composable — allows stores to send WS messages + _sendFn: null as ((data: object) => void) | null, + }), + persist: { + pick: ['internalUUID'], + }, + getters: { + websocketHealthy: (state) => state.isConnected && state.authenticated, + }, + actions: { + // Called by the useWebSocket composable to register the send function + registerSend(fn: (data: object) => void): void { + this._sendFn = fn; + }, + // Called after login to send auth if the WS is already connected + triggerAuthentication(): void { + if (!this._sendFn || !this.pendingAuthentication) return; + const token = localStorage.getItem('digiscript_auth_token'); + if (!token) return; + log.debug('Triggering WS authentication after login'); + this._sendFn({ OP: 'AUTHENTICATE', DATA: { token } }); + }, + // Called after token refresh to keep WS token in sync + refreshWsToken(): void { + if (!this._sendFn || !this.isConnected) return; + const token = localStorage.getItem('digiscript_auth_token'); + if (!token) return; + this._sendFn({ OP: 'REFRESH_TOKEN', DATA: { token } }); + }, + }, +}); diff --git a/client-v3/src/types/api/backup.ts b/client-v3/src/types/api/backup.ts new file mode 100644 index 00000000..9d1dbd54 --- /dev/null +++ b/client-v3/src/types/api/backup.ts @@ -0,0 +1,11 @@ +export interface BackupFile { + filename: string; + size_bytes: number; + created_at: number; +} + +export interface BackupsResponse { + backups: BackupFile[]; + count: number; + total_size_bytes: number; +} diff --git a/client-v3/src/types/api/cues.ts b/client-v3/src/types/api/cues.ts new file mode 100644 index 00000000..5fecb28b --- /dev/null +++ b/client-v3/src/types/api/cues.ts @@ -0,0 +1,13 @@ +export interface CueType { + id: number; + show_id: number | null; + prefix: string | null; + description: string | null; + colour: string | null; +} + +export interface Cue { + id: number; + cue_type_id: number | null; + ident: string | null; +} diff --git a/client-v3/src/types/api/microphones.ts b/client-v3/src/types/api/microphones.ts new file mode 100644 index 00000000..a02952a7 --- /dev/null +++ b/client-v3/src/types/api/microphones.ts @@ -0,0 +1,12 @@ +export interface Microphone { + id: number; + show_id: number | null; + name: string | null; + description: string | null; +} + +export interface MicrophoneAllocation { + mic_id: number; + scene_id: number; + character_id: number; +} diff --git a/client-v3/src/types/api/script.ts b/client-v3/src/types/api/script.ts new file mode 100644 index 00000000..0215fe5d --- /dev/null +++ b/client-v3/src/types/api/script.ts @@ -0,0 +1,58 @@ +export interface ScriptLinePart { + id: number | null; + line_id: number | null; + part_index: number | null; + character_id: number | null; + character_group_id: number | null; + line_text: string | null; +} + +export interface ScriptLine { + id: number | null; + act_id: number | null; + scene_id: number | null; + page: number | null; + line_type: number; + stage_direction_style_id: number | null; + line_parts: ScriptLinePart[]; +} + +export interface ScriptRevision { + id: number; + script_id: number | null; + revision: number | null; + created_at: string | null; + edited_at: string | null; + description: string | null; + previous_revision_id: number | null; + has_draft?: boolean; +} + +export interface StageDirectionStyle { + id: number; + script_id: number | null; + description: string | null; + bold: boolean | null; + italic: boolean | null; + underline: boolean | null; + text_format: string | null; + text_colour: string | null; + enable_background_colour: boolean | null; + background_colour: string | null; +} + +export type ScriptCut = number; + +export interface CompiledScript { + revision_id: number; + created_at: string | null; + updated_at: string | null; + data_path: string | null; +} + +export interface PageStatus { + added: number[]; + updated: number[]; + deleted: number[]; + inserted: number[]; +} diff --git a/client-v3/src/types/api/session.ts b/client-v3/src/types/api/session.ts new file mode 100644 index 00000000..6159599d --- /dev/null +++ b/client-v3/src/types/api/session.ts @@ -0,0 +1,28 @@ +export interface ShowSession { + id: number; + show_id: number; + script_revision_id: number; + start_date_time: string | null; + end_date_time: string | null; + user_id: number | null; + client_internal_id: string | null; + latest_line_ref: string | null; + current_interval_id: number | null; + tags: SessionTag[]; +} + +export interface Interval { + id: number; + session_id: number | null; + act_id: number | null; + start_datetime: string | null; + end_datetime: string | null; + initial_length: number | null; +} + +export interface SessionTag { + id: number; + show_id: number | null; + tag: string; + colour: string; +} diff --git a/client-v3/src/types/api/settings.ts b/client-v3/src/types/api/settings.ts new file mode 100644 index 00000000..05d6fa9c --- /dev/null +++ b/client-v3/src/types/api/settings.ts @@ -0,0 +1,11 @@ +export interface SystemSetting { + key: string; + value: string; +} + +export interface SystemSettings { + current_show: number | null; + client_log_enabled: boolean | null; + client_log_level: string | null; + [key: string]: unknown; +} diff --git a/client-v3/src/types/api/show.ts b/client-v3/src/types/api/show.ts new file mode 100644 index 00000000..92fc7f21 --- /dev/null +++ b/client-v3/src/types/api/show.ts @@ -0,0 +1,54 @@ +export interface Show { + id: number; + name: string | null; + start_date: string | null; + end_date: string | null; + created_at: string | null; + edited_at: string | null; + first_act_id: number | null; + current_session_id: number | null; + script_mode: number; +} + +export interface Cast { + id: number; + show_id: number | null; + first_name: string | null; + last_name: string | null; + character_list: Character[]; +} + +export interface Character { + id: number; + show_id: number | null; + played_by: number | null; + name: string | null; + description: string | null; + cast_member: { id: number; first_name: string | null; last_name: string | null } | null; +} + +export interface CharacterGroup { + id: number; + show_id: number | null; + name: string | null; + description: string | null; +} + +// first_scene and next_act are serialized as IDs by the marshmallow schema +export interface Act { + id: number; + show_id: number | null; + name: string | null; + interval_after: boolean | null; + first_scene: number | null; + next_act: number | null; +} + +// act and next_scene are serialized as IDs by the marshmallow schema +export interface Scene { + id: number; + show_id: number | null; + act: number | null; + name: string | null; + next_scene: number | null; +} diff --git a/client-v3/src/types/api/stage.ts b/client-v3/src/types/api/stage.ts new file mode 100644 index 00000000..e8e38eb8 --- /dev/null +++ b/client-v3/src/types/api/stage.ts @@ -0,0 +1,57 @@ +export interface Crew { + id: number; + show_id: number; + first_name: string; + last_name: string | null; +} + +export interface CrewAssignment { + id: number; + crew_id: number; + scene_id: number; + assignment_type: 'set' | 'strike'; + prop_id: number | null; + scenery_id: number | null; +} + +export interface SceneryType { + id: number; + show_id: number; + name: string; + description: string | null; +} + +export interface Scenery { + id: number; + show_id: number; + scenery_type_id: number; + name: string; + description: string | null; +} + +export interface SceneryAllocation { + id: number; + scenery_id: number; + scene_id: number; +} + +export interface PropType { + id: number; + show_id: number; + name: string; + description: string | null; +} + +export interface Props { + id: number; + show_id: number; + prop_type_id: number; + name: string; + description: string | null; +} + +export interface PropsAllocation { + id: number; + props_id: number; + scene_id: number; +} diff --git a/client-v3/src/types/api/user.ts b/client-v3/src/types/api/user.ts new file mode 100644 index 00000000..98e204a2 --- /dev/null +++ b/client-v3/src/types/api/user.ts @@ -0,0 +1,25 @@ +export interface User { + id: number; + username: string | null; + is_admin: boolean | null; + last_login: string | null; + last_seen: string | null; + requires_password_change: boolean; + token_version: number; +} + +export interface CueColourOverride { + id: number; + cue_type_id: number | null; + colour: string | null; +} + +export interface UserSettings { + enable_script_auto_save: boolean | null; + script_auto_save_interval: number | null; + cue_position_right: boolean | null; + script_text_alignment: number; + console_log_level: string; + character_mru_sort: boolean; + character_combined_dropdown: boolean; +} diff --git a/client-v3/src/types/api/websocket.ts b/client-v3/src/types/api/websocket.ts new file mode 100644 index 00000000..4cc2ab30 --- /dev/null +++ b/client-v3/src/types/api/websocket.ts @@ -0,0 +1,5 @@ +export interface WsMessage { + OP: string; + DATA: Record; + ACTION?: string; +} diff --git a/client-v3/src/types/index.ts b/client-v3/src/types/index.ts new file mode 100644 index 00000000..cf8d175f --- /dev/null +++ b/client-v3/src/types/index.ts @@ -0,0 +1,9 @@ +export type * from './api/show'; +export type * from './api/script'; +export type * from './api/cues'; +export type * from './api/stage'; +export type * from './api/microphones'; +export type * from './api/session'; +export type * from './api/user'; +export type * from './api/settings'; +export type * from './api/websocket'; diff --git a/client-v3/src/views/NotFoundView.vue b/client-v3/src/views/NotFoundView.vue new file mode 100644 index 00000000..bdd3ab08 --- /dev/null +++ b/client-v3/src/views/NotFoundView.vue @@ -0,0 +1,16 @@ + + + Uh Oh... Something went wrong! + (404) Page Not Found! + + + + diff --git a/client-v3/src/views/PlaceholderView.vue b/client-v3/src/views/PlaceholderView.vue new file mode 100644 index 00000000..c8afe46a --- /dev/null +++ b/client-v3/src/views/PlaceholderView.vue @@ -0,0 +1,6 @@ + + + Coming Soon + This page is not yet migrated to Vue 3. + + diff --git a/client-v3/src/views/electron/ServerSelector.vue b/client-v3/src/views/electron/ServerSelector.vue new file mode 100644 index 00000000..20d6690c --- /dev/null +++ b/client-v3/src/views/electron/ServerSelector.vue @@ -0,0 +1,6 @@ + + + Select Server + Electron server selector — coming in Phase 13. + + diff --git a/client-v3/src/views/user/LoginView.vue b/client-v3/src/views/user/LoginView.vue new file mode 100644 index 00000000..30e280ab --- /dev/null +++ b/client-v3/src/views/user/LoginView.vue @@ -0,0 +1,6 @@ + + + Login + Full login UI coming in Phase 3. + + diff --git a/client-v3/tsconfig.json b/client-v3/tsconfig.json index e16e8c1d..90142bbf 100644 --- a/client-v3/tsconfig.json +++ b/client-v3/tsconfig.json @@ -30,6 +30,6 @@ "useDefineForClassFields": true, "noEmit": true }, - "include": ["src/**/*.ts", "src/**/*.vue"], + "include": ["src/**/*.ts", "src/**/*.vue", "components.d.ts"], "exclude": ["node_modules", "dist", "dist-electron", "src/**/*.test.ts"] } diff --git a/client-v3/vite.config.ts b/client-v3/vite.config.ts index 71a7ed2a..e7da66a3 100644 --- a/client-v3/vite.config.ts +++ b/client-v3/vite.config.ts @@ -1,9 +1,16 @@ import path from 'path'; import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; +import Components from 'unplugin-vue-components/vite'; +import { BootstrapVueNextResolver } from 'bootstrap-vue-next'; export default defineConfig({ - plugins: [vue()], + plugins: [ + vue(), + Components({ + resolvers: [BootstrapVueNextResolver()], + }), + ], base: process.env.BUILD_TARGET === 'electron' ? './' : '/ui-new/', build: { outDir:
This page is not yet migrated to Vue 3.
Electron server selector — coming in Phase 13.
Full login UI coming in Phase 3.