├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── lint-types.yml │ ├── release.yml │ └── publish.yml ├── postcss.config.js ├── public ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── logo.svg └── site.webmanifest ├── .gitignore ├── tsconfig.json ├── src ├── views │ ├── ProjectsView.vue │ ├── topics │ │ ├── CreateTopicView.vue │ │ ├── TopicDetailsView.vue │ │ └── PublishMessagesView.vue │ ├── AnalyticsView.vue │ ├── subscriptions │ │ ├── CreateSubscriptionView.vue │ │ ├── ConsumeMessagesView.vue │ │ └── SubscriptionDetailsView.vue │ ├── auth │ │ ├── LoginView.vue │ │ └── CallbackView.vue │ ├── monitoring │ │ ├── AlertsView.vue │ │ ├── MetricsView.vue │ │ └── MonitoringDashboardView.vue │ ├── schemas │ │ ├── SchemasListView.vue │ │ ├── CreateSchemaView.vue │ │ └── SchemaDetailsView.vue │ ├── error │ │ ├── NotFoundView.vue │ │ ├── ForbiddenView.vue │ │ └── ServerErrorView.vue │ ├── fullscreen │ │ ├── MessageViewerView.vue │ │ └── MetricsDashboardView.vue │ └── DashboardView.vue ├── components │ ├── firestore │ │ ├── navigation │ │ │ └── SlidingContainer.vue │ │ ├── fields │ │ │ └── FieldList.vue │ │ ├── layout │ │ │ └── PageHeader.vue │ │ ├── CollectionsList.vue │ │ ├── mobile │ │ │ ├── MobileCollectionsList.vue │ │ │ ├── MobileDocumentsList.vue │ │ │ └── MobileSubcollectionDocuments.vue │ │ ├── columns │ │ │ ├── ColumnOne.vue │ │ │ └── ColumnTwo.vue │ │ ├── DatabaseSelector.vue │ │ ├── DocumentEditorForm.vue │ │ └── FieldModal.vue │ ├── ui │ │ ├── SuccessNotification.vue │ │ ├── PaginationFooter.vue │ │ ├── TemplateVariableInput.vue │ │ ├── KeyValueInput.vue │ │ └── MessageAttributeInput.vue │ ├── modals │ │ └── ConfirmationModal.vue │ └── import-export │ │ ├── StorageImportExport.vue │ │ └── FirestoreImportExport.vue ├── layouts │ ├── AuthLayout.vue │ └── FullscreenLayout.vue ├── utils │ ├── importExportUtils.ts │ ├── errorSetup.ts │ ├── pubsubAttributes.ts │ ├── focusUtils.ts │ ├── propertyConverters.ts │ └── errorMessages.ts ├── composables │ ├── useFieldOperations.ts │ ├── usePagination.ts │ ├── useSaveAndAddAnother.ts │ ├── useDeepNavigation.ts │ ├── useColumnFieldOperations.ts │ ├── useDocumentForm.ts │ ├── useFieldNavigation.ts │ ├── useServiceConnections.ts │ ├── useApiConnection.ts │ ├── useStorageImportExport.ts │ ├── useKeyboardShortcuts.ts │ ├── useFirestoreStorage.ts │ ├── useMessagePublisher.ts │ └── useDocumentUtils.ts ├── stores │ └── config.ts ├── api │ └── client.ts ├── plugins │ └── global-components.ts └── types │ └── index.ts ├── env.d.ts ├── .prettierrc.json ├── tsconfig.node.json ├── .dockerignore ├── deployments ├── Dockerfile ├── generate-runtime-config.sh └── nginx.conf ├── .env.example ├── tsconfig.app.json ├── tailwind.config.js ├── .eslintrc.cjs ├── .devcontainer └── devcontainer.json ├── LICENSE ├── package.json ├── docker-compose.yml └── eslint.config.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [drehelis] 2 | buy_me_a_coffee: drehelis 3 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | '@tailwindcss/postcss': {}, 4 | }, 5 | } -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drehelis/gcp-emulator-ui/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drehelis/gcp-emulator-ui/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | node_modules 4 | dist 5 | dev-dist 6 | 7 | 8 | .claude 9 | CLAUDE.md 10 | repomix.md -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drehelis/gcp-emulator-ui/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drehelis/gcp-emulator-ui/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | E 4 | -------------------------------------------------------------------------------- /src/views/ProjectsView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /src/views/topics/CreateTopicView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /src/views/topics/TopicDetailsView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /src/views/AnalyticsView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /src/views/topics/PublishMessagesView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_PUBSUB_BASE_URL: string 5 | readonly VITE_STORAGE_BASE_URL: string 6 | readonly VITE_GOOGLE_CLOUD_PROJECT_ID: string 7 | readonly VITE_VERSION: string 8 | } 9 | 10 | interface ImportMeta { 11 | readonly env: ImportMetaEnv 12 | } 13 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "bracketSameLine": false, 10 | "arrowParens": "avoid", 11 | "endOfLine": "lf", 12 | "vueIndentScriptAndStyle": false 13 | } -------------------------------------------------------------------------------- /src/views/subscriptions/CreateSubscriptionView.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | "module": "ESNext", 15 | "moduleResolution": "Bundler", 16 | "types": [ 17 | "node" 18 | ] 19 | } 20 | } -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } -------------------------------------------------------------------------------- /src/components/firestore/navigation/SlidingContainer.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | 20 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git files 2 | .git 3 | .gitignore 4 | 5 | # Documentation 6 | README.md 7 | *.md 8 | 9 | # IDE files 10 | .vscode 11 | .idea 12 | *.swp 13 | *.swo 14 | 15 | # Build artifacts 16 | *.exe 17 | 18 | # Test files 19 | *_test.go 20 | 21 | # Logs 22 | *.log 23 | 24 | # OS generated files 25 | .DS_Store 26 | Thumbs.db 27 | 28 | # Docker files (except Dockerfile) 29 | docker-compose*.yml 30 | .dockerignore 31 | 32 | # GitHub workflows (not needed in container) 33 | .github/ 34 | 35 | # Environment files 36 | .env 37 | .env.local 38 | .env.*.local 39 | -------------------------------------------------------------------------------- /deployments/Dockerfile: -------------------------------------------------------------------------------- 1 | # Build 2 | FROM node:25-alpine AS build-stage 3 | 4 | WORKDIR /src 5 | 6 | COPY package*.json ./ 7 | 8 | RUN npm ci 9 | 10 | COPY . . 11 | 12 | RUN npm run build 13 | 14 | # Runtime 15 | FROM nginx:alpine 16 | 17 | RUN apk add --no-cache jq 18 | 19 | COPY deployments/nginx.conf /etc/nginx/templates/app.conf.template 20 | COPY deployments/generate-runtime-config.sh /docker-entrypoint.d/99-generate-runtime-config.sh 21 | COPY --from=build-stage /src/dist /usr/share/nginx/html 22 | 23 | EXPOSE 80 24 | 25 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directories: 5 | - "/" 6 | schedule: 7 | interval: "daily" 8 | groups: 9 | npm-dependencies: 10 | update-types: 11 | - "minor" 12 | - "patch" 13 | 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: daily 18 | groups: 19 | actions-dependencies: 20 | update-types: 21 | - "minor" 22 | - "patch" 23 | 24 | - package-ecosystem: "docker" 25 | directories: 26 | - "/deployments" 27 | schedule: 28 | interval: "weekly" 29 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Runtime env vars for production 2 | # 3 | # PUBSUB_EMULATOR_URL=: 4 | # PUBSUB_PRE_CONFIGURED_MSG_ATTR='{"eventType":"default", "source":"emulator"}' 5 | # 6 | # STORAGE_EMULATOR_URL=: 7 | # FIRESTORE_EMULATOR_URL=: 8 | # DATASTORE_EMULATOR_URL=: 9 | # DATASTORE_FILE_SERVER_URL=: 10 | # 11 | # Build time env vars used by Vite for building 12 | # 13 | # VITE_PUBSUB_BASE_URL=http://: 14 | # VITE_STORAGE_BASE_URL=http://: 15 | # VITE_FIRESTORE_BASE_URL=http://: 16 | # VITE_DATASTORE_BASE_URL=http://: 17 | # VITE_FILE_SERVER_BASE_URL=http://: 18 | -------------------------------------------------------------------------------- /src/views/auth/LoginView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/monitoring/AlertsView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/monitoring/MetricsView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/schemas/SchemasListView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/error/NotFoundView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/schemas/CreateSchemaView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/schemas/SchemaDetailsView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/error/ForbiddenView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/fullscreen/MessageViewerView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": [ 4 | "env.d.ts", 5 | "src/**/*", 6 | "src/**/*.vue" 7 | ], 8 | "exclude": [ 9 | "src/**/__tests__/*" 10 | ], 11 | "compilerOptions": { 12 | "composite": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 14 | "baseUrl": ".", 15 | "paths": { 16 | "@/*": [ 17 | "./src/*" 18 | ] 19 | }, 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "exactOptionalPropertyTypes": true, 24 | "noImplicitReturns": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "noImplicitOverride": true 27 | } 28 | } -------------------------------------------------------------------------------- /src/views/subscriptions/ConsumeMessagesView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/fullscreen/MetricsDashboardView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/auth/CallbackView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/error/ServerErrorView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/monitoring/MonitoringDashboardView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /src/views/subscriptions/SubscriptionDetailsView.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import { fontFamily } from 'tailwindcss/defaultTheme' 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: [ 6 | './index.html', 7 | './src/**/*.{vue,js,ts,jsx,tsx}', 8 | ], 9 | theme: { 10 | extend: { 11 | fontFamily: { 12 | sans: ['Inter var', 'Inter', ...fontFamily.sans], 13 | mono: ['JetBrains Mono', 'Fira Code', ...fontFamily.mono], 14 | }, 15 | colors: { 16 | primary: { 17 | 50: '#f0f9ff', 18 | 100: '#e0f2fe', 19 | 200: '#bae6fd', 20 | 300: '#7dd3fc', 21 | 400: '#38bdf8', 22 | 500: '#0ea5e9', 23 | 600: '#0284c7', 24 | 700: '#0369a1', 25 | 800: '#075985', 26 | 900: '#0c4a6e', 27 | 950: '#082f49', 28 | }, 29 | }, 30 | screens: { 31 | 'xs': '475px', 32 | '3xl': '1920px', 33 | }, 34 | }, 35 | }, 36 | } -------------------------------------------------------------------------------- /src/layouts/AuthLayout.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript', 10 | '@vue/eslint-config-prettier/skip-formatting' 11 | ], 12 | parserOptions: { 13 | ecmaVersion: 'latest' 14 | }, 15 | rules: { 16 | 'vue/multi-word-component-names': 'off', 17 | '@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }], 18 | 'prefer-const': 'error', 19 | 'no-var': 'error', 20 | 'object-shorthand': 'error', 21 | 'prefer-template': 'error' 22 | }, 23 | overrides: [ 24 | { 25 | files: ['cypress/e2e/**/*.{cy,spec}.{js,ts,jsx,tsx}', 'cypress/support/**/*.{js,ts,jsx,tsx}'], 26 | extends: ['plugin:cypress/recommended'] 27 | }, 28 | { 29 | files: ['src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 30 | extends: ['plugin:storybook/recommended'] 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/go 3 | { 4 | "name": "Node.js & TypeScript", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:24", 7 | "customizations": { 8 | "vscode": { 9 | "extensions": [ 10 | "Vue.volar" 11 | ] 12 | } 13 | }, 14 | // Features to add to the dev container. More info: https://containers.dev/features. 15 | // "features": {}, 16 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 17 | // "forwardPorts": [], 18 | // Use 'postCreateCommand' to run commands after the container is created. 19 | "postCreateCommand": "npm install -g @anthropic-ai/claude-code" 20 | // Configure tool-specific properties. 21 | // "customizations": {}, 22 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 23 | // "remoteUser": "root" 24 | } -------------------------------------------------------------------------------- /.github/workflows/lint-types.yml: -------------------------------------------------------------------------------- 1 | name: Type-check & Lint 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | type-check: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v6 13 | 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v6 16 | with: 17 | node-version: '24' 18 | cache: 'npm' 19 | cache-dependency-path: package-lock.json 20 | 21 | - name: Install dependencies 22 | run: | 23 | npm ci 24 | 25 | - name: Run type-check 26 | run: | 27 | npm run type-check 28 | 29 | lint: 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v6 34 | 35 | - name: Setup Node.js 36 | uses: actions/setup-node@v6 37 | with: 38 | node-version: '24' 39 | cache: 'npm' 40 | cache-dependency-path: package-lock.json 41 | 42 | - name: Install dependencies 43 | run: | 44 | npm ci 45 | 46 | - name: Run lint 47 | run: | 48 | npm run lint -- --ignore-pattern '**/__tests__/**' 49 | -------------------------------------------------------------------------------- /src/components/firestore/fields/FieldList.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Danny Rehelis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/importExportUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Shared utilities for import/export functionality 3 | */ 4 | 5 | /** 6 | * Download a file to the user's device 7 | */ 8 | export function downloadFile(content: string, filename: string, mimeType: string): void { 9 | const blob = new Blob([content], { type: mimeType }) 10 | const url = URL.createObjectURL(blob) 11 | const a = document.createElement('a') 12 | a.href = url 13 | a.download = filename 14 | document.body.appendChild(a) 15 | a.click() 16 | document.body.removeChild(a) 17 | URL.revokeObjectURL(url) 18 | } 19 | 20 | /** 21 | * Extract topic name from full Pub/Sub topic path 22 | */ 23 | export function extractTopicName(fullName: string): string { 24 | if (fullName.includes('/topics/')) { 25 | return fullName.split('/topics/')[1] 26 | } 27 | return fullName 28 | } 29 | 30 | /** 31 | * Extract subscription name from full Pub/Sub subscription path 32 | */ 33 | export function extractSubscriptionName(fullName: string): string { 34 | if (fullName.includes('/subscriptions/')) { 35 | return fullName.split('/subscriptions/')[1] 36 | } 37 | return fullName 38 | } 39 | -------------------------------------------------------------------------------- /src/composables/useFieldOperations.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { useFirestoreFields } from '@/composables/useFirestoreFields' 3 | 4 | export const useFieldOperations = () => { 5 | const expandedMapFields = ref>(new Set()) 6 | const { formatFieldValue, getFieldType, getEditableValue } = useFirestoreFields() 7 | 8 | const toggleMapField = (fieldName: string) => { 9 | if (expandedMapFields.value.has(fieldName)) { 10 | expandedMapFields.value.delete(fieldName) 11 | } else { 12 | expandedMapFields.value.add(fieldName) 13 | } 14 | } 15 | 16 | const isMapFieldExpanded = (fieldName: string): boolean => { 17 | return expandedMapFields.value.has(fieldName) 18 | } 19 | 20 | const clearExpandedFields = () => { 21 | expandedMapFields.value.clear() 22 | } 23 | 24 | const restoreExpandedFields = (fieldsSet: Set) => { 25 | expandedMapFields.value = fieldsSet 26 | } 27 | 28 | return { 29 | expandedMapFields, 30 | toggleMapField, 31 | isMapFieldExpanded, 32 | clearExpandedFields, 33 | restoreExpandedFields, 34 | formatFieldValue, 35 | getFieldType, 36 | getEditableValue 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /deployments/generate-runtime-config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | CONFIG_FILE="${CONFIG_FILE:-/usr/share/nginx/html/config.json}" 4 | 5 | echo "Generating runtime configuration..." 6 | 7 | # Start with empty JSON object 8 | CONFIG_JSON="{}" 9 | 10 | if [ -n "$PUBSUB_PRE_CONFIGURED_MSG_ATTR" ]; then 11 | echo "Processing PUBSUB_PRE_CONFIGURED_MSG_ATTR..." 12 | 13 | if echo "$PUBSUB_PRE_CONFIGURED_MSG_ATTR" | jq empty 2>/dev/null; then 14 | echo " [✓] Valid JSON detected" 15 | 16 | CONFIG_JSON=$(echo "$CONFIG_JSON" | jq --argjson attr "$PUBSUB_PRE_CONFIGURED_MSG_ATTR" \ 17 | '.pubsub.pubsubPreConfiguredMsgAttr = $attr') 18 | 19 | if [ $? -eq 0 ]; then 20 | echo " [✓] Successfully added to configuration" 21 | else 22 | echo " [✗] Failed to merge JSON configuration" 23 | CONFIG_JSON="{}" 24 | fi 25 | else 26 | echo " [✗] Warning: PUBSUB_PRE_CONFIGURED_MSG_ATTR is not valid JSON, ignoring" 27 | fi 28 | else 29 | echo "No PUBSUB_PRE_CONFIGURED_MSG_ATTR environment variable found" 30 | fi 31 | 32 | # Write the final configuration using jq for pretty formatting 33 | echo "$CONFIG_JSON" | jq '.' > "$CONFIG_FILE" 34 | -------------------------------------------------------------------------------- /src/utils/errorSetup.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Error handling utilities 3 | * Global error handling setup and utilities 4 | */ 5 | 6 | import type { App } from 'vue' 7 | 8 | export function setupErrorHandling(app: App) { 9 | // Vue error handler 10 | app.config.errorHandler = (error, instance, info) => { 11 | console.error('Vue error:', error) 12 | console.error('Component instance:', instance) 13 | console.error('Error info:', info) 14 | } 15 | 16 | // Global unhandled promise rejection handler 17 | window.addEventListener('unhandledrejection', (event) => { 18 | console.error('Unhandled promise rejection:', event.reason) 19 | 20 | if (import.meta.env.PROD) { 21 | reportError(event.reason, { context: 'promise' }) 22 | } 23 | 24 | // Prevent the default browser error handling 25 | event.preventDefault() 26 | }) 27 | 28 | // Global error handler 29 | window.addEventListener('error', (event) => { 30 | console.error('Global error:', event.error) 31 | 32 | if (import.meta.env.PROD) { 33 | reportError(event.error, { 34 | context: 'global', 35 | filename: event.filename, 36 | lineno: event.lineno, 37 | colno: event.colno 38 | }) 39 | } 40 | }) 41 | } 42 | 43 | function reportError(/* error: any, context: Record = {} */) { 44 | // TODO: Implement actual error reporting (Sentry, LogRocket, etc.) 45 | 46 | // Example Sentry integration: 47 | // if (window.Sentry) { 48 | // window.Sentry.captureException(error, { 49 | // extra: context, 50 | // tags: { 51 | // component: 'error-handler' 52 | // } 53 | // }) 54 | // } 55 | } -------------------------------------------------------------------------------- /src/components/ui/SuccessNotification.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | -------------------------------------------------------------------------------- /src/views/DashboardView.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version bump' 8 | required: true 9 | default: 'patch' 10 | type: choice 11 | options: 12 | - prerelease 13 | - prepatch 14 | - preminor 15 | - premajor 16 | - patch 17 | - minor 18 | - major 19 | 20 | jobs: 21 | release: 22 | runs-on: ubuntu-latest 23 | 24 | permissions: 25 | contents: write 26 | pull-requests: write 27 | 28 | steps: 29 | - name: "Checkout repository" 30 | uses: actions/checkout@v6 31 | 32 | - name: "Bump to new version" 33 | id: bump 34 | run: | 35 | _version=$(npm --no-git-tag-version version ${{ github.event.inputs.version }}) 36 | 37 | echo "new_version=$_version" >> $GITHUB_OUTPUT 38 | echo "New version: $_version" 39 | 40 | - name: "Create Pull Request" 41 | id: cpr 42 | uses: peter-evans/create-pull-request@v8 43 | with: 44 | token: ${{ secrets.RELEASE_PAT }} 45 | title: "Release version ${{ env.NEW_VERSION }}" 46 | body: "Release version ${{ env.NEW_VERSION }}" 47 | commit-message: "chore(release): bump version to ${{ env.NEW_VERSION }}" 48 | env: 49 | NEW_VERSION: ${{ steps.bump.outputs.new_version }} 50 | 51 | - name: "Release" 52 | env: 53 | # https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow#triggering-a-workflow-from-a-workflow 54 | GITHUB_TOKEN: ${{ secrets.RELEASE_PAT }} 55 | NEW_VERSION: ${{ steps.bump.outputs.new_version }} 56 | run: | 57 | gh release create $NEW_VERSION \ 58 | -t $NEW_VERSION 59 | 60 | - name: "Auto-merge PR" 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | run: | 64 | gh pr merge --squash --auto "${{ steps.cpr.outputs.pull-request-number }}" 65 | -------------------------------------------------------------------------------- /src/utils/pubsubAttributes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions for handling Pub/Sub pre-configured message attributes 3 | */ 4 | 5 | import { computed } from 'vue' 6 | import { useConfigStore } from '@/stores/config' 7 | 8 | interface MessageAttribute { 9 | key: string 10 | value: string 11 | } 12 | 13 | /** 14 | * Gets pre-configured message attributes from runtime configuration (reactive) 15 | * Returns a computed ref with array of key-value pairs that can be used in the PublishMessageModal 16 | * 17 | * @returns Computed ref of MessageAttribute objects array 18 | */ 19 | export function usePreconfiguredMessageAttributes() { 20 | const configStore = useConfigStore() 21 | return computed(() => { 22 | const attributes = configStore.pubsubPreConfiguredAttributes || {} 23 | return convertObjectToMessageAttributes(attributes) 24 | }) 25 | } 26 | 27 | /** 28 | * Converts an object to MessageAttribute array with validation 29 | */ 30 | function convertObjectToMessageAttributes(attributesObj: Record): MessageAttribute[] { 31 | try { 32 | if (!attributesObj || typeof attributesObj !== 'object' || Array.isArray(attributesObj)) { 33 | return [] 34 | } 35 | 36 | // Convert object to array of MessageAttribute objects 37 | const attributes: MessageAttribute[] = Object.entries(attributesObj) 38 | .filter(([key, value]) => { 39 | // Ensure both key and value are strings 40 | if (typeof key !== 'string' || typeof value !== 'string') { 41 | console.warn(`Ignoring attribute with invalid key or value: ${key}=${value}`) 42 | return false 43 | } 44 | // Only require key to be non-empty, allow empty values 45 | return key.trim() !== '' 46 | }) 47 | .map(([key, value]) => ({ 48 | key: key.trim(), 49 | value: value.trim() 50 | })) 51 | 52 | return attributes 53 | 54 | } catch (error) { 55 | console.warn('Failed to process pre-configured message attributes:', error) 56 | return [] 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/composables/usePagination.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed, type Ref } from 'vue' 2 | 3 | export interface PaginationOptions { 4 | initialLimit?: number 5 | defaultLimits?: number[] 6 | } 7 | 8 | export function usePagination(options: PaginationOptions = {}) { 9 | const { 10 | initialLimit = 25, 11 | defaultLimits = [10, 25, 50, 100] 12 | } = options 13 | 14 | const limit = ref(initialLimit) 15 | const currentPage = ref(1) 16 | 17 | const paginationStart = computed(() => { 18 | return (currentPage.value - 1) * limit.value + 1 19 | }) 20 | 21 | const paginationEnd = computed(() => { 22 | return currentPage.value * limit.value 23 | }) 24 | 25 | const handleLimitChange = () => { 26 | currentPage.value = 1 27 | } 28 | 29 | const nextPage = () => { 30 | currentPage.value++ 31 | } 32 | 33 | const previousPage = () => { 34 | if (currentPage.value > 1) { 35 | currentPage.value-- 36 | } 37 | } 38 | 39 | const resetPage = () => { 40 | currentPage.value = 1 41 | } 42 | 43 | return { 44 | limit, 45 | currentPage, 46 | paginationStart, 47 | paginationEnd, 48 | defaultLimits, 49 | handleLimitChange, 50 | nextPage, 51 | previousPage, 52 | resetPage 53 | } 54 | } 55 | 56 | export interface PaginatedData { 57 | items: Ref 58 | totalCount: Ref 59 | } 60 | 61 | export function usePaginatedData( 62 | data: PaginatedData, 63 | pagination: ReturnType 64 | ) { 65 | const paginatedItems = computed(() => { 66 | const startIndex = (pagination.currentPage.value - 1) * pagination.limit.value 67 | const endIndex = startIndex + pagination.limit.value 68 | return data.items.value.slice(startIndex, endIndex) 69 | }) 70 | 71 | const hasMore = computed(() => { 72 | return data.totalCount.value > pagination.currentPage.value * pagination.limit.value 73 | }) 74 | 75 | const actualPaginationEnd = computed(() => { 76 | if (data.totalCount.value === 0) return 0 77 | return Math.min(pagination.currentPage.value * pagination.limit.value, data.totalCount.value) 78 | }) 79 | 80 | const actualPaginationStart = computed(() => { 81 | if (data.totalCount.value === 0) return 0 82 | return pagination.paginationStart.value 83 | }) 84 | 85 | return { 86 | paginatedItems, 87 | hasMore, 88 | paginationStart: actualPaginationStart, 89 | paginationEnd: actualPaginationEnd 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/composables/useSaveAndAddAnother.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | interface SaveAndAddAnotherConfig> { 4 | entityType: 'document' | 'collection' 5 | validateForm: () => boolean 6 | setValidationError: (_: boolean) => void 7 | buildPayload: () => any 8 | saveEntity: (_: any) => Promise 9 | clearForm: () => void 10 | onSuccess: (_: string) => void 11 | getEntityId: (_: any) => string 12 | formState: T 13 | } 14 | 15 | export function useSaveAndAddAnother>(config?: SaveAndAddAnotherConfig) { 16 | const lastSavedId = ref(null) 17 | const isLoading = ref(false) 18 | 19 | const setLastSaved = (id: string) => { 20 | lastSavedId.value = id 21 | } 22 | 23 | const clearNotification = () => { 24 | lastSavedId.value = null 25 | } 26 | 27 | const getSuccessMessage = (type: 'document' | 'collection', id: string) => { 28 | const entityType = type === 'document' ? 'document' : 'collection' 29 | return `Your previous ${entityType} '${id}' was saved.` 30 | } 31 | 32 | const handleSaveAndAddAnother = async () => { 33 | if (!config) { 34 | throw new Error('Configuration required for handleSaveAndAddAnother') 35 | } 36 | 37 | config.setValidationError(true) 38 | if (!config.validateForm()) return 39 | 40 | try { 41 | isLoading.value = true 42 | 43 | const payload = config.buildPayload() 44 | const result = await config.saveEntity(payload) 45 | 46 | if (result) { 47 | const savedId = config.getEntityId(result) 48 | 49 | // Set notification 50 | setLastSaved(savedId) 51 | 52 | // Clear form but keep field values 53 | config.clearForm() 54 | 55 | // Notify parent but DON'T close modal 56 | config.onSuccess(savedId) 57 | } 58 | } catch (error) { 59 | console.error(`Failed to create ${config.entityType}:`, error) 60 | } finally { 61 | isLoading.value = false 62 | } 63 | } 64 | 65 | const handleClearFields = () => { 66 | if (config?.formState) { 67 | // Reset all form fields 68 | Object.keys(config.formState).forEach(key => { 69 | if (config.formState[key]?.resetForm) { 70 | config.formState[key].resetForm() 71 | } 72 | }) 73 | } 74 | clearNotification() 75 | } 76 | 77 | return { 78 | lastSavedId, 79 | isLoading, 80 | setLastSaved, 81 | clearNotification, 82 | getSuccessMessage, 83 | handleSaveAndAddAnother, 84 | handleClearFields 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/layouts/FullscreenLayout.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 69 | 70 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gcp-emulator-ui", 3 | "private": true, 4 | "version": "0.0.11", 5 | "type": "module", 6 | "scripts": { 7 | "build": "vue-tsc && vite build", 8 | "dev": "vite --host", 9 | "preview": "vite preview", 10 | "lint": "eslint . --fix", 11 | "lint:style": "stylelint **/*.{css,scss,vue} --fix", 12 | "format": "prettier --write src/", 13 | "type-check": "vue-tsc --noEmit --skipLibCheck" 14 | }, 15 | "dependencies": { 16 | "@google-cloud/pubsub": "^5.2.0", 17 | "@headlessui/vue": "^1.7.22", 18 | "@heroicons/vue": "^2.1.5", 19 | "@tailwindcss/postcss": "^4.1.18", 20 | "@tanstack/vue-query": "^5.92.1", 21 | "@vueuse/core": "^14.1.0", 22 | "@vueuse/head": "^2.0.0", 23 | "@vueuse/integrations": "^14.1.0", 24 | "apexcharts": "^5.3.6", 25 | "axios": "^1.13.2", 26 | "chart.js": "^4.5.1", 27 | "chartjs-adapter-date-fns": "^3.0.0", 28 | "date-fns": "^4.1.0", 29 | "dexie": "^4.2.1", 30 | "fuse.js": "^7.1.0", 31 | "hotkeys-js": "^3.13.7", 32 | "idb": "^8.0.0", 33 | "jszip": "^3.10.1", 34 | "lodash-es": "^4.17.22", 35 | "pinia": "^3.0.4", 36 | "pinia-plugin-persistedstate": "^4.7.1", 37 | "reconnecting-websocket": "^4.4.0", 38 | "socket.io-client": "^4.7.5", 39 | "tailwindcss": "^4.1.13", 40 | "uuid": "^13.0.0", 41 | "vue": "^3.5.26", 42 | "vue-chartjs": "^5.3.3", 43 | "vue-draggable-next": "^2.2.1", 44 | "vue-router": "^4.6.4", 45 | "vue-toastification": "^2.0.0-rc.5", 46 | "vue3-apexcharts": "^1.10.0", 47 | "vue3-virtual-scroller": "^0.2.1", 48 | "workbox-precaching": "^7.4.0", 49 | "workbox-routing": "^7.4.0", 50 | "workbox-strategies": "^7.4.0" 51 | }, 52 | "devDependencies": { 53 | "@rushstack/eslint-patch": "^1.15.0", 54 | "@tsconfig/node20": "^20.1.8", 55 | "@types/lodash-es": "^4.17.12", 56 | "@types/node": "^25.0.3", 57 | "@types/uuid": "^11.0.0", 58 | "@vitejs/plugin-vue": "^6.0.3", 59 | "@vitejs/plugin-vue-jsx": "^5.1.2", 60 | "@vue/eslint-config-prettier": "^10.0.0", 61 | "@vue/eslint-config-typescript": "^14.0.0", 62 | "@vue/tsconfig": "^0.8.1", 63 | "eslint": "^9.39.2", 64 | "eslint-plugin-vue": "^10.6.2", 65 | "prettier": "^3.7.4", 66 | "prettier-plugin-tailwindcss": "^0.7.2", 67 | "stylelint": "^16.26.1", 68 | "stylelint-config-recommended-vue": "^1.5.0", 69 | "stylelint-config-standard": "^39.0.1", 70 | "typescript": "^5.9.3", 71 | "unplugin-auto-import": "^20.3.0", 72 | "unplugin-vue-components": "^30.0.0", 73 | "vite": "^7.3.0", 74 | "vite-plugin-pwa": "^1.2.0", 75 | "vite-plugin-windicss": "^1.9.3", 76 | "vue-tsc": "^3.1.8", 77 | "workbox-build": "^7.4.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/components/firestore/layout/PageHeader.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 69 | -------------------------------------------------------------------------------- /src/composables/useDeepNavigation.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'vue' 2 | import type { FirestoreDocument, FirestoreCollectionWithMetadata } from '@/types' 3 | import type { NavigationLevel } from '@/composables/useRecursiveNavigation' 4 | 5 | export function useDeepNavigation( 6 | navigationStack: Ref, 7 | documentSubcollections: Ref> 8 | ) { 9 | // Check if Column 1 should show DocumentEditor instead of ColumnOne 10 | const shouldShowDocumentEditorInColumnOne = (levelIndex: number): boolean => { 11 | // Show DocumentEditor in Column 1 ONLY when: 12 | // 1. We're in a subcollection navigation (levelIndex > 0) 13 | // 2. The current level is a subcollection type 14 | // 3. There's a previous level with a selected document that contains subcollections 15 | const currentLevel = navigationStack.value[levelIndex] 16 | return levelIndex > 0 && 17 | currentLevel?.type === 'subcollection' && 18 | getColumnOneDocument(levelIndex) !== null 19 | } 20 | 21 | // Get the document that should be shown in Column 1 DocumentEditor 22 | const getColumnOneDocument = (levelIndex: number): FirestoreDocument | null => { 23 | // Get the selected document from the previous level (parent document containing subcollections) 24 | const previousLevel = navigationStack.value[levelIndex - 1] 25 | if (previousLevel?.selectedItem && 'name' in previousLevel.selectedItem) { 26 | return previousLevel.selectedItem as FirestoreDocument 27 | } 28 | return null 29 | } 30 | 31 | // Get subcollections for the document shown in Column 1 32 | const getColumnOneSubcollections = (levelIndex: number): FirestoreCollectionWithMetadata[] => { 33 | const document = getColumnOneDocument(levelIndex) 34 | if (!document) return [] 35 | 36 | const subcollections = documentSubcollections.value.get(document.name) 37 | return Array.isArray(subcollections) ? subcollections : (subcollections?.collections || []) 38 | } 39 | 40 | // Get the currently selected subcollection for Column 1 DocumentEditor 41 | const getColumnOneSelectedSubcollection = (levelIndex: number): FirestoreCollectionWithMetadata | null => { 42 | // The selected subcollection is the current level's collection if it's a subcollection 43 | const currentLevel = navigationStack.value[levelIndex] 44 | if (currentLevel?.type === 'subcollection') { 45 | return { 46 | id: currentLevel.collectionId || currentLevel.header, 47 | name: currentLevel.header, 48 | path: `${currentLevel.parentPath}/${currentLevel.collectionId || currentLevel.header}` 49 | } as FirestoreCollectionWithMetadata 50 | } 51 | return null 52 | } 53 | 54 | return { 55 | shouldShowDocumentEditorInColumnOne, 56 | getColumnOneDocument, 57 | getColumnOneSubcollections, 58 | getColumnOneSelectedSubcollection 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/firestore/CollectionsList.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | pubsub-emulator: 3 | image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators 4 | command: sh -c 'gcloud beta emulators pubsub start --host-port=0.0.0.0:8085' 5 | ports: 6 | - "8085:8085" 7 | restart: unless-stopped 8 | 9 | firestore-emulator: 10 | image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators 11 | command: sh -c 'gcloud beta emulators firestore start --host-port=0.0.0.0:8086' 12 | ports: 13 | - "8086:8086" 14 | restart: unless-stopped 15 | 16 | firestore-datastore-emulator: 17 | image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators 18 | environment: 19 | - MINISERVE_VERSION=0.32.0 20 | - MINISERVE_PATH=/usr/local/bin/miniserve 21 | command: 22 | - sh 23 | - -c 24 | - | 25 | curl -L https://github.com/svenstaro/miniserve/releases/download/v$${MINISERVE_VERSION}/miniserve-$${MINISERVE_VERSION}-$(arch)-unknown-linux-gnu -o $${MINISERVE_PATH} && 26 | chmod +x $${MINISERVE_PATH} && 27 | mkdir -p /srv && 28 | $${MINISERVE_PATH} --port 9999 \ 29 | --upload-files \ 30 | --mkdir \ 31 | --enable-tar-gz \ 32 | --on-duplicate-files=overwrite \ 33 | --route-prefix /fs \ 34 | --header "Access-Control-Allow-Origin: *" \ 35 | --header "Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS" \ 36 | --header "Access-Control-Allow-Headers: *" \ 37 | /srv & 38 | gcloud beta emulators firestore start --database-mode=datastore-mode --host-port=0.0.0.0:8087 39 | ports: 40 | - "8087:8087" 41 | - "9999:9999" 42 | restart: unless-stopped 43 | 44 | storage-emulator: 45 | image: fsouza/fake-gcs-server 46 | command: -scheme http 47 | ports: 48 | - "4443:4443" 49 | restart: unless-stopped 50 | 51 | gcp-emulator-ui: 52 | # image: ghcr.io/drehelis/gcp-emulator-ui:main 53 | build: 54 | context: . 55 | dockerfile: deployments/Dockerfile 56 | ports: 57 | - "9090:80" 58 | environment: 59 | - PUBSUB_EMULATOR_URL=host.docker.internal:8085 60 | - FIRESTORE_EMULATOR_URL=host.docker.internal:8086 61 | - DATASTORE_EMULATOR_URL=host.docker.internal:8087 62 | - DATASTORE_FILE_SERVER_URL=host.docker.internal:9999 63 | - STORAGE_EMULATOR_URL=host.docker.internal:4443 64 | depends_on: 65 | - pubsub-emulator 66 | - firestore-emulator 67 | - firestore-datastore-emulator 68 | - storage-emulator 69 | restart: unless-stopped 70 | -------------------------------------------------------------------------------- /src/components/ui/PaginationFooter.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 78 | -------------------------------------------------------------------------------- /src/components/modals/ConfirmationModal.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | -------------------------------------------------------------------------------- /src/utils/focusUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Focus and highlight utilities for navigation 3 | */ 4 | import { nextTick } from 'vue' 5 | 6 | /** 7 | * Applies a smooth fade-out highlight effect to an element 8 | */ 9 | export const applyFocusHighlight = async (element: HTMLElement) => { 10 | // Add a smooth fade-out highlight effect using CSS variable 11 | const duration = window.getComputedStyle(document.documentElement).getPropertyValue('--highlight-transition-duration').trim() || '1.5s' 12 | const timing = window.getComputedStyle(document.documentElement).getPropertyValue('--highlight-transition-timing').trim() || 'ease-out' 13 | element.style.transition = `background-color ${duration} ${timing}` 14 | element.style.backgroundColor = 'rgba(59, 130, 246, 0.25)' 15 | 16 | // Start fading out after a brief moment 17 | setTimeout(() => { 18 | element.style.backgroundColor = 'rgba(59, 130, 246, 0.08)' 19 | }, 500) 20 | 21 | // Complete fade out 22 | setTimeout(() => { 23 | element.style.backgroundColor = '' 24 | }, 3000) 25 | 26 | // Clean up transition 27 | setTimeout(() => { 28 | element.style.transition = '' 29 | }, 4000) 30 | } 31 | 32 | /** 33 | * Handles focus targeting for topics, subscriptions, and buckets 34 | */ 35 | export const handleFocusTarget = async (targetName: string, targetType: 'topic' | 'subscription' | 'bucket' = 'topic') => { 36 | // Wait for DOM to be ready 37 | await nextTick() 38 | 39 | // Give minimal time for page to load 40 | setTimeout(async () => { 41 | const elementId = `${targetType}-${targetName}` 42 | const targetElement = document.getElementById(elementId) 43 | 44 | if (targetElement) { 45 | // Scroll to the element immediately 46 | targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) 47 | 48 | // Apply highlight effect 49 | await applyFocusHighlight(targetElement) 50 | } 51 | }, 300) 52 | } 53 | 54 | // eslint-disable-next-line no-unused-vars 55 | type TopicNameExtractor = (name: string) => string 56 | 57 | /** 58 | * Handles focus for collapsed topics in subscriptions view 59 | */ 60 | export const handleTopicFocus = async ( 61 | hash: string, 62 | subscriptionsByTopic: Map, 63 | expandedTopics: Set, 64 | getTopicDisplayName: TopicNameExtractor 65 | ) => { 66 | 67 | if (!hash || subscriptionsByTopic.size === 0) { 68 | return 69 | } 70 | 71 | // Find the topic that matches the hash 72 | let targetTopicEntry = Array.from(subscriptionsByTopic).find(([topicName]) => { 73 | const displayName = getTopicDisplayName(topicName) 74 | return displayName === hash 75 | }) 76 | 77 | // If exact match fails, try partial match 78 | if (!targetTopicEntry) { 79 | targetTopicEntry = Array.from(subscriptionsByTopic).find(([topicName]) => { 80 | const displayName = getTopicDisplayName(topicName) 81 | return displayName.includes(hash) || hash.includes(displayName) 82 | }) 83 | } 84 | 85 | if (targetTopicEntry) { 86 | const [topicName] = targetTopicEntry 87 | 88 | // Expand the target topic 89 | expandedTopics.add(topicName) 90 | 91 | // Wait for DOM update and apply focus 92 | await handleFocusTarget(getTopicDisplayName(topicName), 'topic') 93 | } 94 | } -------------------------------------------------------------------------------- /src/composables/useColumnFieldOperations.ts: -------------------------------------------------------------------------------- 1 | import { ref, type Ref } from 'vue' 2 | import type { FirestoreDocument } from '@/types' 3 | 4 | export interface FieldOperationContext { 5 | document: FirestoreDocument | null 6 | column: 'column-one' | 'column-three' 7 | } 8 | 9 | 10 | export function useColumnFieldOperations( 11 | modalManager: any, 12 | deepNavigation: any, 13 | getColumnThreeDocument: (_levelIndex: number) => FirestoreDocument | null, 14 | handleEditField: (_data: any) => void, 15 | currentStackIndex: Ref 16 | ) { 17 | // Track which document context is being edited 18 | const activeFieldOperationContext = ref({ 19 | document: null, 20 | column: 'column-three' 21 | }) 22 | 23 | // Context-aware field operation handlers for Column One 24 | const handleColumnOneFieldOperations = { 25 | addField: () => { 26 | const document = deepNavigation.getColumnOneDocument(currentStackIndex.value) 27 | console.log('Column One addField - document:', document?.name, 'path:', document?.path) 28 | if (document) { 29 | activeFieldOperationContext.value = { document, column: 'column-one' } 30 | console.log('Column One addField - context set to:', activeFieldOperationContext.value) 31 | modalManager.openAddFieldModal() 32 | } 33 | }, 34 | editField: (data: any) => { 35 | const document = deepNavigation.getColumnOneDocument(currentStackIndex.value) 36 | if (document) { 37 | activeFieldOperationContext.value = { document, column: 'column-one' } 38 | handleEditField(data) 39 | } 40 | }, 41 | deleteField: (data: any) => { 42 | const document = deepNavigation.getColumnOneDocument(currentStackIndex.value) 43 | if (document) { 44 | activeFieldOperationContext.value = { document, column: 'column-one' } 45 | modalManager.openDeleteFieldModal(data) 46 | } 47 | } 48 | } 49 | 50 | // Context-aware field operation handlers for Column Three 51 | const handleColumnThreeFieldOperations = { 52 | addField: () => { 53 | const document = getColumnThreeDocument(currentStackIndex.value) 54 | console.log('Column Three addField - document:', document?.name, 'path:', document?.path) 55 | if (document) { 56 | activeFieldOperationContext.value = { document, column: 'column-three' } 57 | console.log('Column Three addField - context set to:', activeFieldOperationContext.value) 58 | modalManager.openAddFieldModal() 59 | } 60 | }, 61 | editField: (data: any) => { 62 | const document = getColumnThreeDocument(currentStackIndex.value) 63 | if (document) { 64 | activeFieldOperationContext.value = { document, column: 'column-three' } 65 | handleEditField(data) 66 | } 67 | }, 68 | deleteField: (data: any) => { 69 | const document = getColumnThreeDocument(currentStackIndex.value) 70 | if (document) { 71 | activeFieldOperationContext.value = { document, column: 'column-three' } 72 | modalManager.openDeleteFieldModal(data) 73 | } 74 | } 75 | } 76 | 77 | return { 78 | activeFieldOperationContext, 79 | handleColumnOneFieldOperations, 80 | handleColumnThreeFieldOperations 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import pluginVue from 'eslint-plugin-vue' 3 | import * as parserVue from 'vue-eslint-parser' 4 | import tsPlugin from '@typescript-eslint/eslint-plugin' 5 | import tsParser from '@typescript-eslint/parser' 6 | 7 | export default [ 8 | // Base JS config 9 | js.configs.recommended, 10 | 11 | // Vue configs 12 | ...pluginVue.configs['flat/essential'], 13 | 14 | // Main config 15 | { 16 | files: ['**/*.{js,mjs,cjs,ts,vue}'], 17 | languageOptions: { 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | globals: { 21 | // Browser globals 22 | window: 'readonly', 23 | document: 'readonly', 24 | console: 'readonly', 25 | localStorage: 'readonly', 26 | sessionStorage: 'readonly', 27 | navigator: 'readonly', 28 | setTimeout: 'readonly', 29 | clearTimeout: 'readonly', 30 | setInterval: 'readonly', 31 | clearInterval: 'readonly', 32 | btoa: 'readonly', 33 | atob: 'readonly', 34 | fetch: 'readonly', 35 | URL: 'readonly', 36 | URLSearchParams: 'readonly', 37 | Blob: 'readonly', 38 | File: 'readonly', 39 | FileReader: 'readonly', 40 | FormData: 'readonly', 41 | HTMLElement: 'readonly', 42 | HTMLInputElement: 'readonly', 43 | Element: 'readonly', 44 | Event: 'readonly', 45 | DragEvent: 'readonly', 46 | AbortController: 'readonly', 47 | AbortSignal: 'readonly', 48 | TextDecoder: 'readonly', 49 | TextEncoder: 'readonly', 50 | KeyboardEvent: 'readonly', 51 | alert: 'readonly', 52 | Notification: 'readonly', 53 | // IndexedDB globals 54 | indexedDB: 'readonly', 55 | IDBDatabase: 'readonly', 56 | IDBRequest: 'readonly', 57 | IDBOpenDBRequest: 'readonly', 58 | // Vite global constants 59 | __APP_VERSION__: 'readonly' 60 | } 61 | }, 62 | rules: { 63 | 'vue/multi-word-component-names': 'off', 64 | 'prefer-const': 'error', 65 | 'no-var': 'error', 66 | 'object-shorthand': 'error', 67 | 'prefer-template': 'error', 68 | 'no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }] 69 | } 70 | }, 71 | 72 | // TypeScript specific config 73 | { 74 | files: ['**/*.{ts,tsx,vue}'], 75 | languageOptions: { 76 | parser: parserVue, 77 | parserOptions: { 78 | parser: tsParser, 79 | ecmaVersion: 'latest', 80 | sourceType: 'module' 81 | } 82 | }, 83 | plugins: { 84 | '@typescript-eslint': tsPlugin 85 | }, 86 | rules: { 87 | '@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }] 88 | } 89 | }, 90 | 91 | // Ignore patterns 92 | { 93 | ignores: [ 94 | 'dist/**', 95 | 'dev-dist/**', 96 | 'node_modules/**', 97 | '.nuxt/**', 98 | '.output/**', 99 | 'coverage/**', 100 | '*.min.js', 101 | 'auto-imports.d.ts', 102 | 'components.d.ts', 103 | '.eslintrc.cjs', 104 | 'sw.js', 105 | 'workbox-*.js' 106 | ] 107 | } 108 | ] -------------------------------------------------------------------------------- /src/components/firestore/mobile/MobileCollectionsList.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 75 | -------------------------------------------------------------------------------- /src/components/ui/TemplateVariableInput.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 85 | -------------------------------------------------------------------------------- /src/stores/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration Store 3 | * Manages both build-time and runtime configuration 4 | */ 5 | 6 | import { defineStore } from 'pinia' 7 | import { ref, computed, readonly } from 'vue' 8 | 9 | interface RuntimeConfig { 10 | pubsub?: { 11 | pubsubPreConfiguredMsgAttr?: Record 12 | // Add other pubsub configuration properties here 13 | [key: string]: any 14 | } 15 | // Add other service configuration sections here 16 | [key: string]: any 17 | } 18 | 19 | export const useConfigStore = defineStore('config', () => { 20 | // State 21 | const runtimeConfig = ref({}) 22 | const isLoaded = ref(false) 23 | const isLoading = ref(false) 24 | const error = ref(null) 25 | 26 | let initPromise: Promise | null = null 27 | 28 | // Getters 29 | const pubsubPreConfiguredAttributes = computed((): Record => { 30 | // Use only runtime configuration - no build-time fallbacks 31 | if (runtimeConfig.value.pubsub?.pubsubPreConfiguredMsgAttr) { 32 | return runtimeConfig.value.pubsub.pubsubPreConfiguredMsgAttr 33 | } 34 | 35 | return {} 36 | }) 37 | 38 | // Actions 39 | async function loadRuntimeConfig() { 40 | if (initPromise) return initPromise 41 | 42 | // Already loaded, skip 43 | if (isLoaded.value) return 44 | 45 | isLoading.value = true 46 | error.value = null 47 | 48 | initPromise = (async () => { 49 | try { 50 | const response = await fetch('/config.json', { 51 | method: 'GET', 52 | headers: { 53 | 'Accept': 'application/json', 54 | 'Cache-Control': 'no-cache' 55 | } 56 | }) 57 | 58 | if (response.ok) { 59 | const config = await response.json() 60 | runtimeConfig.value = config 61 | } else if (response.status !== 404) { 62 | console.warn('Failed to load runtime config:', response.status, response.statusText) 63 | } 64 | } catch (err) { 65 | console.warn('Runtime config loading failed:', err) 66 | error.value = err instanceof Error ? err.message : 'Unknown error' 67 | } finally { 68 | isLoaded.value = true 69 | isLoading.value = false 70 | initPromise = null 71 | } 72 | })() 73 | 74 | return initPromise 75 | } 76 | 77 | async function refreshConfig() { 78 | isLoaded.value = false 79 | initPromise = null 80 | await loadRuntimeConfig() 81 | } 82 | 83 | function updateRuntimeConfig(config: Partial) { 84 | runtimeConfig.value = { ...runtimeConfig.value, ...config } 85 | } 86 | 87 | loadRuntimeConfig() 88 | 89 | return { 90 | // State 91 | runtimeConfig: readonly(runtimeConfig), 92 | isLoaded: readonly(isLoaded), 93 | isLoading: readonly(isLoading), 94 | error: readonly(error), 95 | 96 | // Getters 97 | pubsubPreConfiguredAttributes, 98 | 99 | // Actions 100 | loadRuntimeConfig, 101 | refreshConfig, 102 | updateRuntimeConfig 103 | } 104 | }) 105 | -------------------------------------------------------------------------------- /src/components/import-export/StorageImportExport.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 91 | -------------------------------------------------------------------------------- /src/components/import-export/FirestoreImportExport.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 91 | -------------------------------------------------------------------------------- /src/components/firestore/columns/ColumnOne.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 102 | -------------------------------------------------------------------------------- /src/composables/useDocumentForm.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed } from 'vue' 2 | 3 | export interface Field { 4 | id: string 5 | name: string 6 | type: string 7 | value: any 8 | } 9 | 10 | export function useDocumentForm() { 11 | // Form state 12 | const documentId = ref('') 13 | const fields = ref([]) 14 | const loading = ref(false) 15 | 16 | // Computed 17 | const isFormValid = computed(() => { 18 | // No specific validation required for documents - empty documents are allowed 19 | return true 20 | }) 21 | 22 | // Helper functions 23 | const generateFieldId = () => { 24 | return `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 25 | } 26 | 27 | const buildFirestoreValue = (value: any): any => { 28 | if (value === null || value === undefined) { 29 | return { nullValue: null } 30 | } 31 | 32 | if (typeof value === 'string') { 33 | return { stringValue: value } 34 | } 35 | 36 | if (typeof value === 'number') { 37 | return Number.isInteger(value) 38 | ? { integerValue: value.toString() } 39 | : { doubleValue: value } 40 | } 41 | 42 | if (typeof value === 'boolean') { 43 | return { booleanValue: value } 44 | } 45 | 46 | if (Array.isArray(value)) { 47 | return { 48 | arrayValue: { 49 | values: value.map(item => buildFirestoreValue(item)) 50 | } 51 | } 52 | } 53 | 54 | if (typeof value === 'object') { 55 | const fields: Record = {} 56 | for (const [key, val] of Object.entries(value)) { 57 | // Skip temporary keys (empty field names) 58 | if (!key.startsWith('_temp_')) { 59 | fields[key] = buildFirestoreValue(val) 60 | } 61 | } 62 | return { mapValue: { fields } } 63 | } 64 | 65 | return { stringValue: String(value) } 66 | } 67 | 68 | const buildDocumentFields = () => { 69 | const documentFields: Record = {} 70 | for (const field of fields.value) { 71 | if (field.name.trim()) { 72 | documentFields[field.name] = buildFirestoreValue(field.value) 73 | } 74 | } 75 | 76 | // If no fields have names, create an empty document 77 | if (Object.keys(documentFields).length === 0) { 78 | // Firestore requires at least some content, so we'll create a timestamp field 79 | documentFields['created_at'] = buildFirestoreValue(new Date().toISOString()) 80 | } 81 | 82 | return documentFields 83 | } 84 | 85 | // Field management 86 | const addField = () => { 87 | fields.value.push({ 88 | id: generateFieldId(), 89 | name: '', 90 | type: 'string', 91 | value: '' 92 | }) 93 | } 94 | 95 | const updateFieldValue = (fieldId: string, updates: Partial) => { 96 | const field = fields.value.find(f => f.id === fieldId) 97 | if (field) { 98 | Object.assign(field, updates) 99 | } 100 | } 101 | 102 | const removeField = (fieldId: string) => { 103 | const index = fields.value.findIndex(f => f.id === fieldId) 104 | if (index > -1) { 105 | fields.value.splice(index, 1) 106 | 107 | // Ensure there's always at least one field 108 | if (fields.value.length === 0) { 109 | fields.value.push({ 110 | id: generateFieldId(), 111 | name: '', 112 | type: 'string', 113 | value: '' 114 | }) 115 | } 116 | } 117 | } 118 | 119 | // Reset form 120 | const resetForm = () => { 121 | documentId.value = '' 122 | fields.value = [{ 123 | id: generateFieldId(), 124 | name: '', 125 | type: 'string', 126 | value: '' 127 | }] 128 | loading.value = false 129 | } 130 | 131 | // Initialize with one empty field 132 | resetForm() 133 | 134 | return { 135 | // State 136 | documentId, 137 | fields, 138 | loading, 139 | 140 | // Computed 141 | isFormValid, 142 | 143 | // Methods 144 | addField, 145 | updateFieldValue, 146 | removeField, 147 | resetForm, 148 | buildDocumentFields, 149 | buildFirestoreValue 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - '.github/**' 10 | - README.md 11 | - LICENSE 12 | tags: 13 | - 'v*' 14 | 15 | env: 16 | REGISTRY_IMAGE: ghcr.io/${{ github.repository }} 17 | REGISTRY: ghcr.io 18 | IMAGE_NAME: ${{ github.repository }} 19 | 20 | permissions: 21 | packages: write 22 | attestations: write 23 | id-token: write 24 | 25 | jobs: 26 | build: 27 | strategy: 28 | matrix: 29 | os: [ubuntu-24.04, ubuntu-24.04-arm] 30 | runs-on: ${{ matrix.os }} 31 | 32 | steps: 33 | - name: "Checkout repository" 34 | uses: actions/checkout@v6 35 | 36 | - name: "Docker meta" 37 | id: meta 38 | uses: docker/metadata-action@v5 39 | with: 40 | images: ${{ env.REGISTRY_IMAGE }} 41 | 42 | - name: "Log in to the Container registry" 43 | uses: docker/login-action@v3 44 | with: 45 | registry: ${{ env.REGISTRY }} 46 | username: ${{ github.actor }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: "Set up Docker Buildx" 50 | uses: docker/setup-buildx-action@v3 51 | 52 | - name: "Build and push by digest" 53 | id: build 54 | uses: docker/build-push-action@v6 55 | with: 56 | context: . 57 | file: deployments/Dockerfile 58 | labels: ${{ steps.meta.outputs.labels }} 59 | outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true 60 | 61 | - name: "Export digest" 62 | run: | 63 | mkdir -p ${{ runner.temp }}/digests 64 | digest="${{ steps.build.outputs.digest }}" 65 | touch "${{ runner.temp }}/digests/${digest#sha256:}" 66 | 67 | - name: "Upload digest" 68 | uses: actions/upload-artifact@v6 69 | with: 70 | name: digests-${{ matrix.os }} 71 | path: ${{ runner.temp }}/digests/* 72 | if-no-files-found: error 73 | retention-days: 1 74 | 75 | merge: 76 | runs-on: ubuntu-latest 77 | needs: [build] 78 | 79 | steps: 80 | - name: "Download digests" 81 | uses: actions/download-artifact@v7 82 | with: 83 | path: ${{ runner.temp }}/digests 84 | pattern: digests-* 85 | merge-multiple: true 86 | 87 | - name: "Log in to the Container registry" 88 | uses: docker/login-action@v3 89 | with: 90 | registry: ${{ env.REGISTRY }} 91 | username: ${{ github.actor }} 92 | password: ${{ secrets.GITHUB_TOKEN }} 93 | 94 | - name: "Set up Docker Buildx" 95 | uses: docker/setup-buildx-action@v3 96 | 97 | - name: "Docker meta" 98 | id: meta 99 | uses: docker/metadata-action@v5 100 | with: 101 | images: ${{ env.REGISTRY_IMAGE }} 102 | flavor: | 103 | latest=false 104 | tags: | 105 | type=ref,event=branch 106 | type=ref,event=pr 107 | type=semver,pattern={{version}} 108 | 109 | - name: "Create manifest list and push" 110 | working-directory: ${{ runner.temp }}/digests 111 | run: | 112 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 113 | $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) 114 | 115 | - name: "Inspect image and get digest" 116 | id: inspect 117 | run: | 118 | digest=$(docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} --format '{{printf "%s" .Manifest.Digest}}') 119 | echo "digest=$digest" >> $GITHUB_OUTPUT 120 | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} 121 | 122 | - name: "Generate artifact attestation" 123 | uses: actions/attest-build-provenance@v3 124 | with: 125 | subject-name: ${{ env.REGISTRY_IMAGE }} 126 | subject-digest: ${{ steps.inspect.outputs.digest }} 127 | push-to-registry: true 128 | -------------------------------------------------------------------------------- /src/components/ui/KeyValueInput.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 128 | -------------------------------------------------------------------------------- /src/composables/useFieldNavigation.ts: -------------------------------------------------------------------------------- 1 | export const useFieldNavigation = () => { 2 | const parseFieldPath = (path: string): string[] => { 3 | const pathParts = [] 4 | let current = path 5 | 6 | while (current.length > 0) { 7 | const dotIndex = current.indexOf('.') 8 | const bracketIndex = current.indexOf('[') 9 | 10 | if (dotIndex === -1 && bracketIndex === -1) { 11 | if (current) pathParts.push(current) 12 | break 13 | } else if (bracketIndex !== -1 && (dotIndex === -1 || bracketIndex < dotIndex)) { 14 | const fieldName = current.substring(0, bracketIndex) 15 | const closingBracket = current.indexOf(']', bracketIndex) 16 | 17 | if (closingBracket === -1) { 18 | throw new Error(`Malformed path: missing closing bracket in "${path}"`) 19 | } 20 | 21 | const arrayIndex = current.substring(bracketIndex + 1, closingBracket) 22 | 23 | if (fieldName) pathParts.push(fieldName) 24 | pathParts.push(`[${arrayIndex}]`) 25 | 26 | current = current.substring(closingBracket + 1) 27 | if (current.startsWith('.')) current = current.substring(1) 28 | } else { 29 | const fieldName = current.substring(0, dotIndex) 30 | if (fieldName) pathParts.push(fieldName) 31 | current = current.substring(dotIndex + 1) 32 | } 33 | } 34 | 35 | return pathParts 36 | } 37 | 38 | const navigateWithParts = (currentRef: any, pathParts: string[], originalPath: string): any => { 39 | if (pathParts.length === 0) { 40 | return currentRef 41 | } 42 | 43 | const [currentPart, ...remainingParts] = pathParts 44 | 45 | if (currentPart.startsWith('[') && currentPart.endsWith(']')) { 46 | const index = parseInt(currentPart.substring(1, currentPart.length - 1)) 47 | 48 | if (isNaN(index)) { 49 | throw new Error(`Invalid array index "${currentPart}" in path "${originalPath}"`) 50 | } 51 | 52 | if (!currentRef?.arrayValue?.values) { 53 | throw new Error(`Expected array at path part "${currentPart}" in "${originalPath}", but found: ${typeof currentRef}`) 54 | } 55 | 56 | if (index < 0 || index >= currentRef.arrayValue.values.length) { 57 | throw new Error(`Array index ${index} out of bounds in path "${originalPath}"`) 58 | } 59 | 60 | const arrayItem = currentRef.arrayValue.values[index] 61 | return navigateWithParts(arrayItem, remainingParts, originalPath) 62 | } else { 63 | let nextRef 64 | 65 | if (currentRef?.mapValue?.fields) { 66 | nextRef = currentRef.mapValue.fields[currentPart] 67 | } else if (currentRef && typeof currentRef === 'object' && currentPart in currentRef) { 68 | nextRef = currentRef[currentPart] 69 | } else { 70 | throw new Error(`Field "${currentPart}" not found in path "${originalPath}". Available fields: ${currentRef?.mapValue?.fields ? Object.keys(currentRef.mapValue.fields).join(', ') : Object.keys(currentRef || {}).join(', ')}`) 71 | } 72 | 73 | if (nextRef === undefined) { 74 | throw new Error(`Field "${currentPart}" is undefined in path "${originalPath}"`) 75 | } 76 | 77 | return navigateWithParts(nextRef, remainingParts, originalPath) 78 | } 79 | } 80 | 81 | const navigateToFieldPath = (fields: any, path: string) => { 82 | const pathParts = parseFieldPath(path) 83 | return navigateWithParts(fields, pathParts, path) 84 | } 85 | 86 | const navigateToParentPath = (fields: any, path: string) => { 87 | const pathParts = parseFieldPath(path) 88 | 89 | if (pathParts.length === 0) { 90 | throw new Error('Cannot navigate to parent of empty path') 91 | } 92 | 93 | if (pathParts.length === 1) { 94 | return { 95 | parent: fields, 96 | lastPart: pathParts[0] 97 | } 98 | } 99 | 100 | const parentParts = pathParts.slice(0, -1) 101 | const lastPart = pathParts[pathParts.length - 1] 102 | 103 | const parent = navigateWithParts(fields, parentParts, path) 104 | 105 | return { 106 | parent, 107 | lastPart 108 | } 109 | } 110 | 111 | return { 112 | parseFieldPath, 113 | navigateToFieldPath, 114 | navigateToParentPath, 115 | navigateWithParts 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /deployments/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream pubsub_emulator { 2 | server ${PUBSUB_EMULATOR_URL}; 3 | } 4 | 5 | upstream storage_emulator { 6 | server ${STORAGE_EMULATOR_URL}; 7 | } 8 | 9 | upstream firestore_emulator { 10 | server ${FIRESTORE_EMULATOR_URL}; 11 | } 12 | 13 | upstream datastore_emulator { 14 | server ${DATASTORE_EMULATOR_URL}; 15 | } 16 | 17 | upstream fileserver_emulator { 18 | server ${DATASTORE_FILE_SERVER_URL}; 19 | } 20 | 21 | server { 22 | listen 80; 23 | server_name localhost; 24 | server_tokens off; 25 | 26 | root /usr/share/nginx/html; 27 | index index.html; 28 | 29 | client_max_body_size 100M; # Allow larger file uploads (100MB) 30 | 31 | 32 | gzip on; 33 | gzip_vary on; 34 | gzip_min_length 1024; 35 | gzip_proxied expired no-cache no-store private auth; 36 | gzip_types 37 | text/plain 38 | text/css 39 | text/xml 40 | text/javascript 41 | application/javascript 42 | application/xml+rss 43 | application/json; 44 | 45 | add_header X-Frame-Options "SAMEORIGIN" always; 46 | add_header X-XSS-Protection "1; mode=block" always; 47 | add_header X-Content-Type-Options "nosniff" always; 48 | add_header Referrer-Policy "no-referrer-when-downgrade" always; 49 | add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; 50 | 51 | location /fs/ { 52 | proxy_pass http://fileserver_emulator/fs/; 53 | proxy_set_header Host $host; 54 | proxy_set_header X-Real-IP $remote_addr; 55 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 56 | proxy_set_header X-Forwarded-Proto $scheme; 57 | 58 | proxy_pass_request_headers on; 59 | 60 | proxy_connect_timeout 60s; 61 | proxy_send_timeout 300s; 62 | proxy_read_timeout 300s; 63 | 64 | proxy_buffering off; 65 | proxy_request_buffering off; 66 | 67 | add_header Access-Control-Allow-Origin "*" always; 68 | } 69 | 70 | location ~ ^/(pubsub|storage|firestore|datastore)/(.*)$ { 71 | set $emulator_service $1; 72 | 73 | if ($request_method = 'OPTIONS') { 74 | add_header Access-Control-Allow-Origin "*"; 75 | add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS"; 76 | add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"; 77 | add_header Access-Control-Max-Age 3600; 78 | add_header Content-Type "text/plain charset=UTF-8"; 79 | add_header Content-Length 0; 80 | return 204; 81 | } 82 | 83 | proxy_pass http://${emulator_service}_emulator/$2$is_args$args; 84 | 85 | proxy_http_version 1.1; 86 | proxy_set_header Upgrade $http_upgrade; 87 | proxy_set_header Connection 'upgrade'; 88 | proxy_set_header Host $host; 89 | proxy_set_header X-Real-IP $remote_addr; 90 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 91 | proxy_set_header X-Forwarded-Proto $scheme; 92 | proxy_cache_bypass $http_upgrade; 93 | 94 | proxy_connect_timeout 60s; 95 | proxy_send_timeout 60s; 96 | proxy_read_timeout 300s; 97 | 98 | add_header Access-Control-Allow-Origin "*" always; 99 | add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always; 100 | add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization" always; 101 | add_header Access-Control-Max-Age 3600 always; 102 | } 103 | 104 | location = /config.json { 105 | try_files /config.json =404; 106 | add_header Content-Type application/json; 107 | add_header Cache-Control "no-cache, no-store, must-revalidate"; 108 | add_header Pragma "no-cache"; 109 | add_header Expires "0"; 110 | } 111 | 112 | location / { 113 | try_files $uri $uri/ /index.html; 114 | 115 | add_header Cache-Control "no-cache, no-store, must-revalidate"; 116 | add_header Pragma "no-cache"; 117 | add_header Expires "0"; 118 | } 119 | 120 | location ~ ^/_(pubsub|storage|firestore|datastore)-hc/(.*)$ { 121 | proxy_pass http://$1_emulator/$2$is_args$args; 122 | } 123 | 124 | location /health { 125 | access_log off; 126 | return 200 "healthy\n"; 127 | add_header Content-Type text/plain; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/composables/useServiceConnections.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { ref } from 'vue' 4 | 5 | export interface ServiceConnectionStatus { 6 | pubsub: boolean 7 | storage: boolean 8 | firestore: boolean 9 | datastore: boolean 10 | } 11 | 12 | // Reactive connection status 13 | const pubsubConnected = ref(false) 14 | const storageConnected = ref(false) 15 | const firestoreConnected = ref(false) 16 | const datastoreConnected = ref(false) 17 | 18 | /** 19 | * Composable for managing GCP emulator service connections 20 | */ 21 | export function useServiceConnections() { 22 | /** 23 | * Check Pub/Sub emulator connection 24 | */ 25 | const checkPubSubConnection = async (): Promise => { 26 | try { 27 | const baseUrl = import.meta.env.VITE_PUBSUB_BASE_URL || '/_pubsub-hc' 28 | const response = await fetch(`${baseUrl}/`, { 29 | method: 'GET', 30 | signal: AbortSignal.timeout(3000) 31 | }) 32 | pubsubConnected.value = response.ok 33 | return response.ok 34 | } catch (error) { 35 | console.warn('Pub/Sub emulator connection check failed:', error) 36 | pubsubConnected.value = false 37 | return false 38 | } 39 | } 40 | 41 | /** 42 | * Check Storage emulator connection 43 | */ 44 | const checkStorageConnection = async (): Promise => { 45 | try { 46 | const baseUrl = import.meta.env.VITE_STORAGE_BASE_URL || '/_storage-hc' 47 | const response = await fetch(`${baseUrl}/_internal/healthcheck`, { 48 | method: 'GET', 49 | signal: AbortSignal.timeout(3000) 50 | }) 51 | storageConnected.value = response.ok 52 | return response.ok 53 | } catch (error) { 54 | console.warn('Storage emulator connection check failed:', error) 55 | storageConnected.value = false 56 | return false 57 | } 58 | } 59 | 60 | /** 61 | * Check Firestore emulator connection 62 | */ 63 | const checkFirestoreConnection = async (): Promise => { 64 | try { 65 | const baseUrl = import.meta.env.VITE_FIRESTORE_BASE_URL || '/_firestore-hc' 66 | const response = await fetch(`${baseUrl}/`, { 67 | method: 'GET', 68 | signal: AbortSignal.timeout(3000) 69 | }) 70 | firestoreConnected.value = response.ok 71 | return response.ok 72 | } catch (error) { 73 | console.warn('Firestore emulator connection check failed:', error) 74 | firestoreConnected.value = false 75 | return false 76 | } 77 | } 78 | 79 | /** 80 | * Check Datastore emulator connection 81 | */ 82 | const checkDatastoreConnection = async (): Promise => { 83 | try { 84 | const baseUrl = import.meta.env.VITE_DATASTORE_BASE_URL || '/_datastore-hc' 85 | const response = await fetch(`${baseUrl}/`, { 86 | method: 'GET', 87 | signal: AbortSignal.timeout(3000) 88 | }) 89 | datastoreConnected.value = response.ok 90 | return response.ok 91 | } catch (error) { 92 | console.warn('Datastore emulator connection check failed:', error) 93 | datastoreConnected.value = false 94 | return false 95 | } 96 | } 97 | 98 | /** 99 | * Check all service connections 100 | */ 101 | const checkAllConnections = async (): Promise => { 102 | const [pubsub, storage, firestore, datastore] = await Promise.all([ 103 | checkPubSubConnection(), 104 | checkStorageConnection(), 105 | checkFirestoreConnection(), 106 | checkDatastoreConnection() 107 | ]) 108 | 109 | return { 110 | pubsub, 111 | storage, 112 | firestore, 113 | datastore 114 | } 115 | } 116 | 117 | return { 118 | // Reactive status 119 | pubsubConnected, 120 | storageConnected, 121 | firestoreConnected, 122 | datastoreConnected, 123 | 124 | // Methods 125 | checkPubSubConnection, 126 | checkStorageConnection, 127 | checkFirestoreConnection, 128 | checkDatastoreConnection, 129 | checkAllConnections 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/components/ui/MessageAttributeInput.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 106 | -------------------------------------------------------------------------------- /src/components/firestore/mobile/MobileDocumentsList.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 102 | -------------------------------------------------------------------------------- /src/api/client.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API Client setup and configuration 3 | * Centralized HTTP client with interceptors and error handling 4 | */ 5 | 6 | import axios, { type AxiosInstance, type AxiosResponse } from 'axios' 7 | import type { ApiResponse, ApiError } from '@/types' 8 | 9 | let apiClient: AxiosInstance 10 | 11 | export function setupApiClient() { 12 | // Create axios instance 13 | apiClient = axios.create({ 14 | baseURL: import.meta.env.VITE_PUBSUB_BASE_URL || '/pubsub', 15 | timeout: 30000, 16 | headers: { 17 | 'Content-Type': 'application/json', 18 | }, 19 | }) 20 | 21 | // Request interceptor 22 | apiClient.interceptors.request.use( 23 | (config) => { 24 | const token = localStorage.getItem('auth-token') 25 | if (token) { 26 | config.headers.Authorization = `Bearer ${token}` 27 | } 28 | 29 | // Add request ID for tracking 30 | config.headers['X-Request-ID'] = generateRequestId() 31 | 32 | return config 33 | }, 34 | (error) => { 35 | console.error('Request interceptor error:', error) 36 | return Promise.reject(error) 37 | } 38 | ) 39 | 40 | // Response interceptor 41 | apiClient.interceptors.response.use( 42 | (response: AxiosResponse) => { 43 | return response 44 | }, 45 | (error) => { 46 | // Handle different error types 47 | if (error.response) { 48 | // Server responded with error status 49 | const apiError: ApiError = { 50 | code: error.response.status, 51 | message: error.response.data?.message || error.message, 52 | details: error.response.data?.details || [], 53 | timestamp: new Date(), 54 | requestId: error.config?.headers?.['X-Request-ID'] 55 | } 56 | 57 | console.error('API Error:', apiError) 58 | 59 | // Handle specific status codes 60 | switch (error.response.status) { 61 | case 401: 62 | // Unauthorized - clear auth and redirect to login 63 | localStorage.removeItem('auth-token') 64 | window.location.href = '/auth/login' 65 | break 66 | case 403: 67 | // Forbidden 68 | console.warn('Access denied') 69 | break 70 | case 429: 71 | // Rate limited 72 | console.warn('Rate limit exceeded') 73 | break 74 | case 500: 75 | // Server error 76 | console.error('Server error') 77 | break 78 | } 79 | 80 | return Promise.reject(apiError) 81 | } else if (error.request) { 82 | // Network error 83 | const networkError: ApiError = { 84 | code: 0, 85 | message: 'Network error - please check your connection', 86 | details: [], 87 | timestamp: new Date() 88 | } 89 | 90 | console.error('Network error:', networkError) 91 | return Promise.reject(networkError) 92 | } else { 93 | // Other error 94 | const unknownError: ApiError = { 95 | code: -1, 96 | message: error.message || 'Unknown error occurred', 97 | details: [], 98 | timestamp: new Date() 99 | } 100 | 101 | console.error('Unknown error:', unknownError) 102 | return Promise.reject(unknownError) 103 | } 104 | } 105 | ) 106 | } 107 | 108 | function generateRequestId(): string { 109 | return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 110 | } 111 | 112 | export function getApiClient(): AxiosInstance { 113 | if (!apiClient) { 114 | throw new Error('API client not initialized. Call setupApiClient() first.') 115 | } 116 | return apiClient 117 | } 118 | 119 | /** 120 | * Create a request with custom timeout for long-running operations 121 | */ 122 | export function createLongRunningRequest(timeoutMs: number = 300000) { // 5 minute default 123 | if (!apiClient) { 124 | throw new Error('API client not initialized. Call setupApiClient() first.') 125 | } 126 | 127 | return { 128 | get: (url: string, config = {}) => apiClient.get(url, { ...config, timeout: timeoutMs }), 129 | post: (url: string, data?: any, config = {}) => apiClient.post(url, data, { ...config, timeout: timeoutMs }), 130 | put: (url: string, data?: any, config = {}) => apiClient.put(url, data, { ...config, timeout: timeoutMs }), 131 | delete: (url: string, config = {}) => apiClient.delete(url, { ...config, timeout: timeoutMs }), 132 | patch: (url: string, data?: any, config = {}) => apiClient.patch(url, data, { ...config, timeout: timeoutMs }) 133 | } 134 | } 135 | 136 | export { apiClient as default } -------------------------------------------------------------------------------- /src/composables/useApiConnection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API Connection Status Composable 3 | * Manages and monitors API connection status 4 | */ 5 | 6 | import { ref, computed } from 'vue' 7 | import { healthApi } from '@/api/pubsub' 8 | import { useAppStore } from '@/stores/app' 9 | 10 | type ConnectionStatus = 'unknown' | 'checking' | 'connected' | 'disconnected' | 'error' 11 | 12 | interface ConnectionState { 13 | status: ConnectionStatus 14 | lastChecked: Date | null 15 | error: string | null 16 | retryCount: number 17 | } 18 | 19 | const connectionState = ref({ 20 | status: 'unknown', 21 | lastChecked: null, 22 | error: null, 23 | retryCount: 0 24 | }) 25 | 26 | let checkInterval: number | null = null 27 | 28 | export function useApiConnection() { 29 | const appStore = useAppStore() 30 | 31 | const isConnected = computed(() => connectionState.value.status === 'connected') 32 | const isDisconnected = computed(() => 33 | connectionState.value.status === 'disconnected' || 34 | connectionState.value.status === 'error' 35 | ) 36 | const isChecking = computed(() => connectionState.value.status === 'checking') 37 | 38 | async function checkConnection(): Promise { 39 | connectionState.value.status = 'checking' 40 | connectionState.value.lastChecked = new Date() 41 | 42 | try { 43 | // Use the health API to check connection 44 | await healthApi.getHealthStatus() 45 | 46 | connectionState.value.status = 'connected' 47 | connectionState.value.error = null 48 | connectionState.value.retryCount = 0 49 | 50 | return true 51 | } catch (error: any) { 52 | connectionState.value.retryCount++ 53 | 54 | if (error.code === 0 || error.code === 'ECONNREFUSED' || error.message?.includes('Network Error')) { 55 | // Network/connection error 56 | connectionState.value.status = 'disconnected' 57 | connectionState.value.error = 'Unable to connect to the API server. Please check if the server is running and accessible.' 58 | } else if (error.response?.status >= 500) { 59 | // Server error 60 | connectionState.value.status = 'error' 61 | connectionState.value.error = `Server error (${error.response.status}): The API server is experiencing issues.` 62 | } else if (error.code === 'ENOTFOUND') { 63 | // DNS/hostname error 64 | connectionState.value.status = 'error' 65 | connectionState.value.error = 'Cannot resolve the API server hostname. Please check the API URL configuration.' 66 | } else { 67 | // Other error 68 | connectionState.value.status = 'error' 69 | connectionState.value.error = `Connection failed: ${error.message || 'Unknown error'}` 70 | } 71 | 72 | return false 73 | } 74 | } 75 | 76 | function startPeriodicCheck(intervalMs: number = 30000) { 77 | if (checkInterval) { 78 | clearInterval(checkInterval) 79 | } 80 | 81 | checkInterval = window.setInterval(() => { 82 | if (connectionState.value.status !== 'checking') { 83 | checkConnection() 84 | } 85 | }, intervalMs) 86 | } 87 | 88 | function stopPeriodicCheck() { 89 | if (checkInterval) { 90 | clearInterval(checkInterval) 91 | checkInterval = null 92 | } 93 | } 94 | 95 | async function retryConnection(): Promise { 96 | appStore.showToast({ 97 | type: 'info', 98 | title: 'Retrying connection...', 99 | message: 'Attempting to reconnect to the API server' 100 | }) 101 | 102 | const success = await checkConnection() 103 | 104 | if (success) { 105 | appStore.showToast({ 106 | type: 'success', 107 | title: 'Connection restored', 108 | message: 'Successfully connected to the API server' 109 | }) 110 | } else { 111 | appStore.showToast({ 112 | type: 'error', 113 | title: 'Connection failed', 114 | message: connectionState.value.error || 'Unable to connect to the API server' 115 | }) 116 | } 117 | 118 | return success 119 | } 120 | 121 | function reset() { 122 | connectionState.value = { 123 | status: 'unknown', 124 | lastChecked: null, 125 | error: null, 126 | retryCount: 0 127 | } 128 | stopPeriodicCheck() 129 | } 130 | 131 | return { 132 | // State 133 | connectionState: computed(() => connectionState.value), 134 | 135 | // Computed 136 | isConnected, 137 | isDisconnected, 138 | isChecking, 139 | 140 | // Actions 141 | checkConnection, 142 | startPeriodicCheck, 143 | stopPeriodicCheck, 144 | retryConnection, 145 | reset 146 | } 147 | } -------------------------------------------------------------------------------- /src/components/firestore/mobile/MobileSubcollectionDocuments.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 102 | -------------------------------------------------------------------------------- /src/plugins/global-components.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Global components plugin 3 | * Registers commonly used components globally 4 | */ 5 | 6 | import type { App } from 'vue' 7 | 8 | // Import commonly used icons from Heroicons 9 | import { 10 | // Navigation icons 11 | HomeIcon, 12 | ChartBarIcon, 13 | QueueListIcon, 14 | InboxStackIcon, 15 | DocumentTextIcon, 16 | CogIcon, 17 | PresentationChartLineIcon, 18 | ChartPieIcon, 19 | FolderIcon, 20 | 21 | // Action icons 22 | PlusIcon, 23 | TrashIcon, 24 | PencilIcon, 25 | EyeIcon, 26 | ArrowPathIcon, 27 | MagnifyingGlassIcon, 28 | FunnelIcon, 29 | Bars3Icon, 30 | XMarkIcon, 31 | 32 | // Status icons 33 | CheckCircleIcon, 34 | ExclamationCircleIcon, 35 | ExclamationTriangleIcon, 36 | InformationCircleIcon, 37 | ClockIcon, 38 | 39 | // Arrow icons 40 | ChevronLeftIcon, 41 | ChevronRightIcon, 42 | ChevronUpIcon, 43 | ChevronDownIcon, 44 | ArrowUpIcon, 45 | ArrowDownIcon, 46 | 47 | // Other common icons 48 | UserIcon, 49 | BellIcon, 50 | SunIcon, 51 | MoonIcon, 52 | ComputerDesktopIcon, 53 | EllipsisVerticalIcon, 54 | ArrowRightOnRectangleIcon, 55 | PaperAirplaneIcon, 56 | ArrowDownTrayIcon, 57 | ArrowsPointingOutIcon, 58 | ArrowsPointingInIcon 59 | } from '@heroicons/vue/24/outline' 60 | 61 | // Import filled versions for active states 62 | import { 63 | HomeIcon as HomeIconSolid, 64 | ChartBarIcon as ChartBarIconSolid, 65 | QueueListIcon as QueueListIconSolid, 66 | InboxStackIcon as InboxStackIconSolid, 67 | DocumentTextIcon as DocumentTextIconSolid, 68 | CogIcon as CogIconSolid, 69 | PresentationChartLineIcon as PresentationChartLineIconSolid, 70 | ChartPieIcon as ChartPieIconSolid, 71 | FolderIcon as FolderIconSolid 72 | } from '@heroicons/vue/24/solid' 73 | 74 | export default { 75 | install(app: App) { 76 | // Register outline icons 77 | app.component('HomeIcon', HomeIcon) 78 | app.component('ChartBarIcon', ChartBarIcon) 79 | app.component('QueueListIcon', QueueListIcon) 80 | app.component('InboxStackIcon', InboxStackIcon) 81 | app.component('DocumentTextIcon', DocumentTextIcon) 82 | app.component('CogIcon', CogIcon) 83 | app.component('PresentationChartLineIcon', PresentationChartLineIcon) 84 | app.component('ChartPieIcon', ChartPieIcon) 85 | app.component('FolderIcon', FolderIcon) 86 | 87 | app.component('PlusIcon', PlusIcon) 88 | app.component('TrashIcon', TrashIcon) 89 | app.component('PencilIcon', PencilIcon) 90 | app.component('EyeIcon', EyeIcon) 91 | app.component('ArrowPathIcon', ArrowPathIcon) 92 | app.component('MagnifyingGlassIcon', MagnifyingGlassIcon) 93 | app.component('FunnelIcon', FunnelIcon) 94 | app.component('Bars3Icon', Bars3Icon) 95 | app.component('XMarkIcon', XMarkIcon) 96 | 97 | app.component('CheckCircleIcon', CheckCircleIcon) 98 | app.component('ExclamationCircleIcon', ExclamationCircleIcon) 99 | app.component('ExclamationTriangleIcon', ExclamationTriangleIcon) 100 | app.component('InformationCircleIcon', InformationCircleIcon) 101 | app.component('ClockIcon', ClockIcon) 102 | 103 | app.component('ChevronLeftIcon', ChevronLeftIcon) 104 | app.component('ChevronRightIcon', ChevronRightIcon) 105 | app.component('ChevronUpIcon', ChevronUpIcon) 106 | app.component('ChevronDownIcon', ChevronDownIcon) 107 | app.component('ArrowUpIcon', ArrowUpIcon) 108 | app.component('ArrowDownIcon', ArrowDownIcon) 109 | 110 | app.component('UserIcon', UserIcon) 111 | app.component('BellIcon', BellIcon) 112 | app.component('SunIcon', SunIcon) 113 | app.component('MoonIcon', MoonIcon) 114 | app.component('ComputerDesktopIcon', ComputerDesktopIcon) 115 | app.component('EllipsisVerticalIcon', EllipsisVerticalIcon) 116 | app.component('ArrowRightOnRectangleIcon', ArrowRightOnRectangleIcon) 117 | app.component('PaperAirplaneIcon', PaperAirplaneIcon) 118 | app.component('ArrowDownTrayIcon', ArrowDownTrayIcon) 119 | app.component('ArrowsPointingOutIcon', ArrowsPointingOutIcon) 120 | app.component('ArrowsPointingInIcon', ArrowsPointingInIcon) 121 | 122 | // Register solid icons with 'Solid' suffix 123 | app.component('HomeIconSolid', HomeIconSolid) 124 | app.component('ChartBarIconSolid', ChartBarIconSolid) 125 | app.component('QueueListIconSolid', QueueListIconSolid) 126 | app.component('InboxStackIconSolid', InboxStackIconSolid) 127 | app.component('DocumentTextIconSolid', DocumentTextIconSolid) 128 | app.component('CogIconSolid', CogIconSolid) 129 | app.component('PresentationChartLineIconSolid', PresentationChartLineIconSolid) 130 | app.component('ChartPieIconSolid', ChartPieIconSolid) 131 | app.component('FolderIconSolid', FolderIconSolid) 132 | } 133 | } -------------------------------------------------------------------------------- /src/components/firestore/DatabaseSelector.vue: -------------------------------------------------------------------------------- 1 | 87 | 88 | -------------------------------------------------------------------------------- /src/components/firestore/DocumentEditorForm.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | -------------------------------------------------------------------------------- /src/utils/propertyConverters.ts: -------------------------------------------------------------------------------- 1 | import type { DatastoreValue } from '@/types/datastore' 2 | import type { PropertyForm } from '@/components/datastore/PropertyEditor.vue' 3 | 4 | /** 5 | * Convert PropertyForm to Datastore value 6 | */ 7 | export function propertyFormToDatastoreValue(property: PropertyForm): DatastoreValue { 8 | const datastoreValue: DatastoreValue = { 9 | excludeFromIndexes: !property.indexed 10 | } 11 | 12 | switch (property.type) { 13 | case 'string': 14 | datastoreValue.stringValue = property.value 15 | break 16 | case 'text': 17 | datastoreValue.stringValue = property.value 18 | datastoreValue.excludeFromIndexes = true // Text is not indexed by default 19 | break 20 | case 'integer': 21 | datastoreValue.integerValue = property.value 22 | break 23 | case 'double': 24 | datastoreValue.doubleValue = parseFloat(property.value) || 0 25 | break 26 | case 'boolean': 27 | datastoreValue.booleanValue = property.value === 'true' 28 | break 29 | case 'timestamp': 30 | // Handle timestamp values 31 | if (property.value) { 32 | // Check if it's a datetime-local format (YYYY-MM-DDTHH:mm or YYYY-MM-DDTHH:mm:ss) 33 | // without milliseconds/microseconds and without Z 34 | if (property.value.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/) && !property.value.includes('.')) { 35 | // datetime-local format - add seconds if needed and Z 36 | const withSeconds = property.value.match(/:\d{2}:\d{2}$/) ? property.value : `${property.value}:00` 37 | datastoreValue.timestampValue = `${withSeconds}Z` 38 | } else { 39 | // Already has RFC 3339 format (possibly with microseconds) - preserve it 40 | datastoreValue.timestampValue = property.value.endsWith('Z') ? property.value : `${property.value}Z` 41 | } 42 | } 43 | break 44 | case 'key': 45 | try { 46 | datastoreValue.keyValue = JSON.parse(property.value) 47 | } catch (e) { 48 | console.error('Invalid key JSON:', e) 49 | datastoreValue.keyValue = {} 50 | } 51 | break 52 | case 'geopoint': { 53 | const [lat, lng] = property.value.split(',').map(v => parseFloat(v.trim())) 54 | datastoreValue.geoPointValue = { 55 | latitude: lat || 0, 56 | longitude: lng || 0 57 | } 58 | break 59 | } 60 | case 'array': 61 | try { 62 | datastoreValue.arrayValue = JSON.parse(property.value) 63 | } catch (e) { 64 | console.error('Invalid array JSON:', e) 65 | datastoreValue.arrayValue = { values: [] } 66 | } 67 | break 68 | case 'entity': 69 | try { 70 | datastoreValue.entityValue = JSON.parse(property.value) 71 | } catch (e) { 72 | console.error('Invalid entity JSON:', e) 73 | datastoreValue.entityValue = { properties: {} } 74 | } 75 | break 76 | case 'null': 77 | datastoreValue.nullValue = null 78 | break 79 | } 80 | 81 | return datastoreValue 82 | } 83 | 84 | /** 85 | * Convert Datastore value to PropertyForm 86 | */ 87 | export function datastoreValueToPropertyForm(key: string, value: DatastoreValue): PropertyForm { 88 | let type: PropertyForm['type'] = 'string' 89 | let val = '' 90 | 91 | if (value.stringValue !== undefined) { 92 | // Distinguish between string and text based on excludeFromIndexes 93 | type = value.excludeFromIndexes ? 'text' : 'string' 94 | val = value.stringValue 95 | } else if (value.integerValue !== undefined) { 96 | type = 'integer' 97 | val = String(value.integerValue) 98 | } else if (value.doubleValue !== undefined) { 99 | type = 'double' 100 | val = String(value.doubleValue) 101 | } else if (value.booleanValue !== undefined) { 102 | type = 'boolean' 103 | val = String(value.booleanValue) 104 | } else if (value.timestampValue !== undefined) { 105 | type = 'timestamp' 106 | // Convert RFC 3339 timestamp to datetime-local format for the input 107 | // "2026-02-06T08:39:28.026860Z" -> "2026-02-06T08:39:28" 108 | // datetime-local input needs YYYY-MM-DDTHH:mm:ss (no milliseconds, no Z) 109 | val = value.timestampValue 110 | .replace(/\.\d+Z?$/, '') // Remove milliseconds/microseconds and Z 111 | .replace(/Z$/, '') // Remove Z if no milliseconds 112 | } else if (value.keyValue !== undefined) { 113 | type = 'key' 114 | val = JSON.stringify(value.keyValue, null, 2) 115 | } else if (value.geoPointValue !== undefined) { 116 | type = 'geopoint' 117 | val = `${value.geoPointValue.latitude},${value.geoPointValue.longitude}` 118 | } else if (value.arrayValue !== undefined) { 119 | type = 'array' 120 | val = JSON.stringify(value.arrayValue, null, 2) 121 | } else if (value.entityValue !== undefined) { 122 | type = 'entity' 123 | val = JSON.stringify(value.entityValue, null, 2) 124 | } else if (value.nullValue !== undefined) { 125 | type = 'null' 126 | val = '' 127 | } 128 | 129 | return { 130 | name: key, 131 | type, 132 | value: val, 133 | indexed: !value.excludeFromIndexes, 134 | expanded: false 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/components/firestore/FieldModal.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | -------------------------------------------------------------------------------- /src/components/firestore/columns/ColumnTwo.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 138 | -------------------------------------------------------------------------------- /src/composables/useStorageImportExport.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | import { useAppStore } from '@/stores/app' 3 | import { useStorageStore } from '@/stores/storage' 4 | import type { StorageBucket, CreateBucketRequest } from '@/types/storage' 5 | import { downloadFile } from '@/utils/importExportUtils' 6 | 7 | interface StorageBucketConfig { 8 | name: string 9 | location?: string 10 | storageClass?: string 11 | uniformBucketLevelAccess?: boolean 12 | publicAccessPrevention?: 'enforced' | 'inherited' 13 | versioning?: boolean 14 | labels?: Record 15 | } 16 | 17 | export function useStorageImportExport() { 18 | const appStore = useAppStore() 19 | const storageStore = useStorageStore() 20 | 21 | const buckets = ref([]) 22 | const isExporting = ref(false) 23 | const isImporting = ref(false) 24 | 25 | // Load data 26 | const loadData = async () => { 27 | try { 28 | await storageStore.fetchBuckets() 29 | buckets.value = storageStore.buckets 30 | } catch (error) { 31 | console.error('Failed to load Storage data:', error) 32 | throw error 33 | } 34 | } 35 | 36 | // Export configuration 37 | const exportConfiguration = async (projectId: string) => { 38 | isExporting.value = true 39 | try { 40 | const exportData: StorageBucketConfig[] = buckets.value.map(bucket => ({ 41 | name: bucket.name, 42 | location: bucket.location || 'US', 43 | storageClass: bucket.storageClass || 'STANDARD', 44 | uniformBucketLevelAccess: bucket.iamConfiguration?.uniformBucketLevelAccess?.enabled || false, 45 | publicAccessPrevention: bucket.iamConfiguration?.publicAccessPrevention || 'inherited', 46 | versioning: bucket.versioning?.enabled || false, 47 | labels: bucket.labels || {} 48 | })) 49 | 50 | // Download the file 51 | downloadFile( 52 | JSON.stringify(exportData, null, 2), 53 | `storage-buckets-${projectId}-${new Date().toISOString().split('T')[0]}.json`, 54 | 'application/json' 55 | ) 56 | 57 | appStore.showToast({ 58 | type: 'success', 59 | title: 'Storage configuration exported', 60 | message: `Exported ${exportData.length} bucket configuration${exportData.length === 1 ? '' : 's'}` 61 | }) 62 | } catch (error) { 63 | console.error('Storage export failed:', error) 64 | appStore.showToast({ 65 | type: 'error', 66 | title: 'Export failed', 67 | message: (error as Error).message 68 | }) 69 | } finally { 70 | isExporting.value = false 71 | } 72 | } 73 | 74 | // Import configuration 75 | const importConfiguration = async (importData: StorageBucketConfig[], options: any) => { 76 | isImporting.value = true 77 | try { 78 | let successCount = 0 79 | let errorCount = 0 80 | 81 | for (const bucketConfig of importData) { 82 | try { 83 | const existingBucket = buckets.value.find(b => b.name === bucketConfig.name) 84 | 85 | if (existingBucket && !options.overwriteExisting) { 86 | successCount++ 87 | continue 88 | } 89 | 90 | const bucketRequest: CreateBucketRequest = { 91 | name: bucketConfig.name, 92 | location: bucketConfig.location || 'US', 93 | storageClass: bucketConfig.storageClass || 'STANDARD', 94 | iamConfiguration: { 95 | uniformBucketLevelAccess: { 96 | enabled: bucketConfig.uniformBucketLevelAccess || false 97 | }, 98 | publicAccessPrevention: bucketConfig.publicAccessPrevention || 'inherited' 99 | } 100 | } 101 | 102 | await storageStore.createBucket(bucketRequest, true) // Silent mode to prevent spam notifications 103 | successCount++ 104 | } catch { 105 | errorCount++ 106 | } 107 | } 108 | 109 | // Reload data 110 | await loadData() 111 | 112 | // Show toast notification 113 | if (successCount > 0 && errorCount === 0) { 114 | appStore.showToast({ 115 | type: 'success', 116 | title: 'Storage import completed successfully', 117 | message: `${successCount} bucket${successCount === 1 ? '' : 's'} imported` 118 | }) 119 | } else if (successCount > 0 && errorCount > 0) { 120 | appStore.showToast({ 121 | type: 'warning', 122 | title: 'Storage import completed with errors', 123 | message: `${successCount} successful, ${errorCount} failed` 124 | }) 125 | } else { 126 | appStore.showToast({ 127 | type: 'error', 128 | title: 'Storage import failed', 129 | message: `All ${errorCount} bucket${errorCount === 1 ? '' : 's'} failed to import` 130 | }) 131 | } 132 | } catch (error) { 133 | console.error('Storage import failed:', error) 134 | appStore.showToast({ 135 | type: 'error', 136 | title: 'Import failed', 137 | message: (error as Error).message 138 | }) 139 | } finally { 140 | isImporting.value = false 141 | } 142 | } 143 | 144 | return { 145 | buckets, 146 | isExporting, 147 | isImporting, 148 | loadData, 149 | exportConfiguration, 150 | importConfiguration 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/composables/useKeyboardShortcuts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Keyboard shortcuts composable 3 | * Provides a unified way to register and manage keyboard shortcuts 4 | */ 5 | 6 | import { ref, onMounted, onUnmounted } from 'vue' 7 | import type { KeyboardShortcut } from '@/types' 8 | import hotkeys from 'hotkeys-js' 9 | 10 | export function useKeyboardShortcuts() { 11 | const registeredShortcuts = ref>(new Map()) 12 | const enabled = ref(true) 13 | 14 | function registerShortcut(shortcut: KeyboardShortcut) { 15 | const key = formatShortcutKey(shortcut) 16 | 17 | registeredShortcuts.value.set(key, shortcut) 18 | 19 | // Configure hotkeys scope 20 | const scope = shortcut.global ? 'global' : 'local' 21 | 22 | // Register with hotkeys library 23 | hotkeys(key, { scope }, (event) => { 24 | if (!enabled.value || (shortcut.enabled !== undefined && !shortcut.enabled)) { 25 | return 26 | } 27 | 28 | // Prevent default behavior 29 | event.preventDefault() 30 | 31 | // Execute action 32 | try { 33 | shortcut.action() 34 | } catch (error) { 35 | console.error('Error executing keyboard shortcut:', error) 36 | } 37 | }) 38 | 39 | console.log(`Registered keyboard shortcut: ${key} - ${shortcut.description}`) 40 | } 41 | 42 | function registerShortcuts(shortcuts: KeyboardShortcut[]) { 43 | shortcuts.forEach(registerShortcut) 44 | } 45 | 46 | function unregisterShortcut(shortcut: KeyboardShortcut) { 47 | const key = formatShortcutKey(shortcut) 48 | 49 | if (registeredShortcuts.value.has(key)) { 50 | hotkeys.unbind(key) 51 | registeredShortcuts.value.delete(key) 52 | console.log(`Unregistered keyboard shortcut: ${key}`) 53 | } 54 | } 55 | 56 | function unregisterShortcuts(shortcuts?: KeyboardShortcut[]) { 57 | if (shortcuts) { 58 | shortcuts.forEach(unregisterShortcut) 59 | } else { 60 | // Unregister all shortcuts 61 | registeredShortcuts.value.forEach((shortcut) => { 62 | const key = formatShortcutKey(shortcut) 63 | hotkeys.unbind(key) 64 | }) 65 | registeredShortcuts.value.clear() 66 | } 67 | } 68 | 69 | function enableShortcuts() { 70 | enabled.value = true 71 | } 72 | 73 | function disableShortcuts() { 74 | enabled.value = false 75 | } 76 | 77 | function toggleShortcuts() { 78 | enabled.value = !enabled.value 79 | } 80 | 81 | function getRegisteredShortcuts(): KeyboardShortcut[] { 82 | return Array.from(registeredShortcuts.value.values()) 83 | } 84 | 85 | function formatShortcutKey(shortcut: KeyboardShortcut): string { 86 | const modifiers = shortcut.modifiers || [] 87 | const parts = [...modifiers, shortcut.key.toLowerCase()] 88 | 89 | // Normalize modifier names for hotkeys library 90 | const normalizedParts = parts.map(part => { 91 | switch (part) { 92 | case 'ctrl': 93 | case 'control': 94 | return 'ctrl' 95 | case 'alt': 96 | case 'option': 97 | return 'alt' 98 | case 'shift': 99 | return 'shift' 100 | case 'meta': 101 | case 'cmd': 102 | case 'command': 103 | return 'cmd' 104 | default: 105 | return part 106 | } 107 | }) 108 | 109 | return normalizedParts.join('+') 110 | } 111 | 112 | function formatDisplayKey(shortcut: KeyboardShortcut): string { 113 | const modifiers = shortcut.modifiers || [] 114 | const parts = [...modifiers, shortcut.key] 115 | 116 | // Format for display (with proper symbols) 117 | const displayParts = parts.map(part => { 118 | switch (part.toLowerCase()) { 119 | case 'ctrl': 120 | case 'control': 121 | return '⌃' 122 | case 'alt': 123 | case 'option': 124 | return '⌥' 125 | case 'shift': 126 | return '⇧' 127 | case 'meta': 128 | case 'cmd': 129 | case 'command': 130 | return '⌘' 131 | case 'enter': 132 | case 'return': 133 | return '↩' 134 | case 'escape': 135 | case 'esc': 136 | return '⎋' 137 | case 'space': 138 | return '␣' 139 | case 'tab': 140 | return '⇥' 141 | case 'backspace': 142 | return '⌫' 143 | case 'delete': 144 | return '⌦' 145 | case 'arrowup': 146 | case 'up': 147 | return '↑' 148 | case 'arrowdown': 149 | case 'down': 150 | return '↓' 151 | case 'arrowleft': 152 | case 'left': 153 | return '←' 154 | case 'arrowright': 155 | case 'right': 156 | return '→' 157 | default: 158 | return part.toUpperCase() 159 | } 160 | }) 161 | 162 | return displayParts.join(' + ') 163 | } 164 | 165 | // Set global scope by default 166 | onMounted(() => { 167 | hotkeys.setScope('global') 168 | }) 169 | 170 | // Cleanup on unmount 171 | onUnmounted(() => { 172 | unregisterShortcuts() 173 | }) 174 | 175 | return { 176 | registeredShortcuts: registeredShortcuts.value, 177 | enabled, 178 | registerShortcut, 179 | registerShortcuts, 180 | unregisterShortcut, 181 | unregisterShortcuts, 182 | enableShortcuts, 183 | disableShortcuts, 184 | toggleShortcuts, 185 | getRegisteredShortcuts, 186 | formatShortcutKey, 187 | formatDisplayKey 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/utils/errorMessages.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Error handling utilities 3 | * Provides meaningful error messages for HTTP errors and API responses 4 | */ 5 | 6 | /** 7 | * Converts generic HTTP errors into meaningful error messages 8 | * @param error - The error object from axios or other HTTP client 9 | * @returns A meaningful error message string 10 | */ 11 | export const getMeaningfulErrorMessage = (error: any): string => { 12 | // Check for specific status codes and provide meaningful messages 13 | if (error.response?.status === 409) { 14 | return 'ALREADY_EXISTS: Resource already exists' 15 | } 16 | if (error.response?.status === 404) { 17 | return 'NOT_FOUND: Resource not found' 18 | } 19 | if (error.response?.status === 400) { 20 | return 'INVALID_REQUEST: Invalid request parameters' 21 | } 22 | if (error.response?.status === 403) { 23 | return 'PERMISSION_DENIED: Access denied' 24 | } 25 | if (error.response?.status === 500) { 26 | return 'INTERNAL_ERROR: Server internal error' 27 | } 28 | 29 | // Check if the error message already contains meaningful information 30 | if (error.message?.includes('ALREADY_EXISTS')) { 31 | return error.message 32 | } 33 | if (error.message?.includes('NOT_FOUND')) { 34 | return error.message 35 | } 36 | if (error.message?.includes('PERMISSION_DENIED')) { 37 | return error.message 38 | } 39 | if (error.message?.includes('INVALID_')) { 40 | return error.message 41 | } 42 | 43 | // For generic HTTP errors, provide meaningful alternatives 44 | if (error.message?.includes('Request failed with status code')) { 45 | const statusMatch = error.message.match(/status code (\d+)/) 46 | if (statusMatch) { 47 | const status = parseInt(statusMatch[1]) 48 | switch (status) { 49 | case 409: return 'ALREADY_EXISTS: Resource already exists' 50 | case 404: return 'NOT_FOUND: Resource not found' 51 | case 400: return 'INVALID_REQUEST: Invalid request parameters' 52 | case 403: return 'PERMISSION_DENIED: Access denied' 53 | case 500: return 'INTERNAL_ERROR: Server internal error' 54 | default: return `HTTP_ERROR: Request failed (${status})` 55 | } 56 | } 57 | } 58 | 59 | // Return original message or fallback 60 | return error.message || 'Unknown error occurred' 61 | } 62 | 63 | /** 64 | * Gets a user-friendly error message for PubSub specific operations 65 | * @param error - The error object 66 | * @param operation - The operation that failed (e.g., 'create subscription', 'delete topic') 67 | * @returns A user-friendly error message 68 | */ 69 | export const getPubSubErrorMessage = (error: any, operation: string): string => { 70 | const baseMessage = getMeaningfulErrorMessage(error) 71 | 72 | // Add context for specific PubSub operations 73 | if (baseMessage.includes('ALREADY_EXISTS')) { 74 | if (operation.includes('subscription')) { 75 | return 'ALREADY_EXISTS: A subscription with this name already exists for this topic. Please choose a different name.' 76 | } 77 | if (operation.includes('topic')) { 78 | return 'ALREADY_EXISTS: A topic with this name already exists. Please choose a different name.' 79 | } 80 | } 81 | 82 | if (baseMessage.includes('NOT_FOUND')) { 83 | if (operation.includes('subscription')) { 84 | return 'NOT_FOUND: The subscription no longer exists. It may have been deleted by another process.' 85 | } 86 | if (operation.includes('topic')) { 87 | return 'NOT_FOUND: The topic no longer exists. Please refresh the page to see the current state.' 88 | } 89 | } 90 | 91 | return baseMessage 92 | } 93 | 94 | /** 95 | * Gets a user-friendly error message for Storage specific operations 96 | * @param error - The error object 97 | * @param operation - The operation that failed (e.g., 'create bucket', 'upload file') 98 | * @returns A user-friendly error message 99 | */ 100 | export const getStorageErrorMessage = (error: any, operation: string): string => { 101 | const baseMessage = getMeaningfulErrorMessage(error) 102 | 103 | // Add context for specific Storage operations 104 | if (baseMessage.includes('ALREADY_EXISTS')) { 105 | if (operation.includes('bucket')) { 106 | return 'A bucket with this name already exists. Bucket names must be globally unique. Please choose a different name.' 107 | } 108 | if (operation.includes('object') || operation.includes('file')) { 109 | return 'A file with this name already exists in this location. Please choose a different name or delete the existing file first.' 110 | } 111 | return 'This resource already exists. Please choose a different name.' 112 | } 113 | 114 | if (baseMessage.includes('NOT_FOUND')) { 115 | if (operation.includes('bucket')) { 116 | return 'The bucket no longer exists. It may have been deleted by another process.' 117 | } 118 | if (operation.includes('object') || operation.includes('file')) { 119 | return 'The file no longer exists. It may have been deleted or moved.' 120 | } 121 | } 122 | 123 | if (baseMessage.includes('PERMISSION_DENIED')) { 124 | return 'You do not have permission to perform this operation. Please check your access rights.' 125 | } 126 | 127 | if (baseMessage.includes('INVALID_REQUEST')) { 128 | if (operation.includes('bucket')) { 129 | return 'Invalid bucket configuration. Please check the bucket name and settings.' 130 | } 131 | return 'Invalid request. Please check your input and try again.' 132 | } 133 | 134 | return baseMessage 135 | } -------------------------------------------------------------------------------- /src/composables/useFirestoreStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Firestore localStorage management composable 3 | * Centralized handling of firestore-related browser storage operations 4 | */ 5 | 6 | export const useFirestoreStorage = () => { 7 | // Storage keys 8 | const STORAGE_KEY_DATABASES = 'firestore-databases' 9 | const STORAGE_KEY_SELECTED_DB = 'firestore-selected-database' 10 | 11 | /** 12 | * Load databases list from localStorage 13 | * @returns Array of database IDs 14 | */ 15 | const loadDatabasesFromStorage = (): string[] => { 16 | try { 17 | const stored = localStorage.getItem(STORAGE_KEY_DATABASES) 18 | return stored ? JSON.parse(stored) : ['(default)'] 19 | } catch (error) { 20 | console.warn('Failed to load databases from localStorage:', error) 21 | return ['(default)'] 22 | } 23 | } 24 | 25 | /** 26 | * Save databases list to localStorage 27 | * @param databaseList - Array of database IDs to save 28 | */ 29 | const saveDatabasesToStorage = (databaseList: string[]): void => { 30 | try { 31 | localStorage.setItem(STORAGE_KEY_DATABASES, JSON.stringify(databaseList)) 32 | } catch (error) { 33 | console.warn('Failed to save databases to localStorage:', error) 34 | } 35 | } 36 | 37 | /** 38 | * Load selected database from localStorage 39 | * @returns Selected database ID 40 | */ 41 | const loadSelectedDatabaseFromStorage = (): string => { 42 | try { 43 | return localStorage.getItem(STORAGE_KEY_SELECTED_DB) || '(default)' 44 | } catch (error) { 45 | console.warn('Failed to load selected database from localStorage:', error) 46 | return '(default)' 47 | } 48 | } 49 | 50 | /** 51 | * Save selected database to localStorage 52 | * @param databaseId - Database ID to save as selected 53 | */ 54 | const saveSelectedDatabaseToStorage = (databaseId: string): void => { 55 | try { 56 | localStorage.setItem(STORAGE_KEY_SELECTED_DB, databaseId) 57 | } catch (error) { 58 | console.warn('Failed to save selected database to localStorage:', error) 59 | } 60 | } 61 | 62 | /** 63 | * Add a database to the stored list if not already present 64 | * @param databaseId - Database ID to add 65 | * @returns Updated databases list 66 | */ 67 | const addDatabaseToStorage = (databaseId: string): string[] => { 68 | const currentDatabases = loadDatabasesFromStorage() 69 | 70 | if (!currentDatabases.includes(databaseId)) { 71 | const updatedDatabases = [...currentDatabases, databaseId] 72 | 73 | // Sort with (default) first, then alphabetically 74 | updatedDatabases.sort((a, b) => { 75 | if (a === '(default)') return -1 76 | if (b === '(default)') return 1 77 | return a.localeCompare(b) 78 | }) 79 | 80 | saveDatabasesToStorage(updatedDatabases) 81 | return updatedDatabases 82 | } 83 | 84 | return currentDatabases 85 | } 86 | 87 | /** 88 | * Remove a database from the stored list 89 | * @param databaseId - Database ID to remove 90 | * @returns Updated databases list 91 | */ 92 | const removeDatabaseFromStorage = (databaseId: string): string[] => { 93 | // Cannot remove the default database 94 | if (databaseId === '(default)') { 95 | const currentDatabases = loadDatabasesFromStorage() 96 | return currentDatabases 97 | } 98 | 99 | const currentDatabases = loadDatabasesFromStorage() 100 | const updatedDatabases = currentDatabases.filter(db => db !== databaseId) 101 | 102 | saveDatabasesToStorage(updatedDatabases) 103 | 104 | // If the removed database was selected, switch to default 105 | const selectedDb = loadSelectedDatabaseFromStorage() 106 | if (selectedDb === databaseId) { 107 | saveSelectedDatabaseToStorage('(default)') 108 | } 109 | 110 | return updatedDatabases 111 | } 112 | 113 | /** 114 | * Clear all firestore storage data 115 | */ 116 | const clearFirestoreStorage = (): void => { 117 | try { 118 | localStorage.removeItem(STORAGE_KEY_DATABASES) 119 | localStorage.removeItem(STORAGE_KEY_SELECTED_DB) 120 | } catch (error) { 121 | console.warn('Failed to clear firestore storage:', error) 122 | } 123 | } 124 | 125 | /** 126 | * Get all firestore storage data 127 | * @returns Object with all stored data 128 | */ 129 | const getFirestoreStorageData = () => { 130 | return { 131 | databases: loadDatabasesFromStorage(), 132 | selectedDatabase: loadSelectedDatabaseFromStorage() 133 | } 134 | } 135 | 136 | /** 137 | * Check if localStorage is available 138 | * @returns True if localStorage is available 139 | */ 140 | const isStorageAvailable = (): boolean => { 141 | try { 142 | const test = '__storage_test__' 143 | localStorage.setItem(test, test) 144 | localStorage.removeItem(test) 145 | return true 146 | } catch { 147 | return false 148 | } 149 | } 150 | 151 | return { 152 | // Core storage operations 153 | loadDatabasesFromStorage, 154 | saveDatabasesToStorage, 155 | loadSelectedDatabaseFromStorage, 156 | saveSelectedDatabaseToStorage, 157 | 158 | // Database management 159 | addDatabaseToStorage, 160 | removeDatabaseFromStorage, 161 | 162 | // Utility functions 163 | clearFirestoreStorage, 164 | getFirestoreStorageData, 165 | isStorageAvailable, 166 | 167 | // Storage keys (for external use if needed) 168 | STORAGE_KEY_DATABASES, 169 | STORAGE_KEY_SELECTED_DB 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Main types export file 3 | * Central location for all TypeScript type definitions 4 | */ 5 | 6 | // Re-export all types 7 | export * from './pubsub' 8 | export * from './storage' 9 | export * from './firestore' 10 | export * from './datastore' 11 | export * from './api' 12 | export * from './ui' 13 | 14 | // Global type augmentations 15 | declare global { 16 | interface Window { 17 | __APP_VERSION__: string 18 | } 19 | } 20 | 21 | // Vue type augmentations 22 | declare module 'vue' { 23 | interface ComponentCustomProperties { 24 | $toast: { 25 | success: Function 26 | error: Function 27 | warning: Function 28 | info: Function 29 | clear: () => void 30 | } 31 | $modal: { 32 | open: Function 33 | close: Function 34 | closeAll: () => void 35 | } 36 | $loading: { 37 | show: Function 38 | hide: () => void 39 | } 40 | $APP_VERSION: string 41 | } 42 | 43 | interface ComponentCustomProps { 44 | loading?: boolean 45 | disabled?: boolean 46 | readonly?: boolean 47 | size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' 48 | variant?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' 49 | } 50 | } 51 | 52 | // Vue Router type augmentations 53 | declare module 'vue-router' { 54 | interface RouteMeta { 55 | title?: string 56 | description?: string 57 | icon?: string 58 | requiresAuth?: boolean 59 | roles?: string[] 60 | permissions?: string[] 61 | layout?: string 62 | breadcrumbs?: Array<{ 63 | label: string 64 | route?: string 65 | disabled?: boolean 66 | }> 67 | } 68 | } 69 | 70 | // Pinia type augmentations (removed problematic interface) 71 | 72 | // Environment variables 73 | export interface ImportMetaEnv { 74 | readonly PUBSUB_PROXY_BASE_URL: string 75 | readonly VITE_VERSION: string 76 | } 77 | 78 | export interface ImportMeta { 79 | readonly env: ImportMetaEnv 80 | } 81 | 82 | // Utility types 83 | export type Nullable = T | null 84 | export type Optional = T | undefined 85 | export type MaybeNull = T | null | undefined 86 | 87 | export type DeepPartial = { 88 | [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] 89 | } 90 | 91 | export type DeepRequired = { 92 | [P in keyof T]-?: T[P] extends object ? DeepRequired : T[P] 93 | } 94 | 95 | export type KeyOf = keyof T 96 | export type ValueOf = T[keyof T] 97 | 98 | export type Awaited = T extends Promise ? U : T 99 | 100 | export type NonEmptyArray = [T, ...T[]] 101 | 102 | export type Flatten = T extends (infer U)[] ? U : T 103 | 104 | // Function types 105 | export type EventHandler = Function 106 | export type ErrorHandler = Function 107 | 108 | // Component types 109 | export type ComponentSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' 110 | export type ComponentVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' 111 | export type ComponentState = 'idle' | 'loading' | 'success' | 'error' 112 | 113 | // Generic event types 114 | export interface BaseEvent { 115 | type: string 116 | timestamp: Date 117 | source?: string 118 | } 119 | 120 | export interface ErrorEvent extends BaseEvent { 121 | type: 'error' 122 | error: Error 123 | context?: Record 124 | } 125 | 126 | export interface SuccessEvent extends BaseEvent { 127 | type: 'success' 128 | message: string 129 | data?: any 130 | } 131 | 132 | export interface WarningEvent extends BaseEvent { 133 | type: 'warning' 134 | message: string 135 | context?: Record 136 | } 137 | 138 | // Store types 139 | export type StoreState = 'idle' | 'loading' | 'success' | 'error' 140 | 141 | export interface BaseStoreState { 142 | state: StoreState 143 | error: string | null 144 | lastUpdated: Date | null 145 | } 146 | 147 | // API types 148 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' 149 | export type ContentType = 'application/json' | 'application/x-www-form-urlencoded' | 'multipart/form-data' 150 | 151 | // Date/Time types 152 | export type DateFormat = 'short' | 'medium' | 'long' | 'full' | 'iso' | 'relative' 153 | export type TimeZone = string // IANA timezone identifier 154 | 155 | // File types 156 | export interface FileInfo { 157 | name: string 158 | size: number 159 | type: string 160 | lastModified: number 161 | } 162 | 163 | export type FileAccept = string | string[] 164 | 165 | // Color types 166 | export type ColorFormat = 'hex' | 'rgb' | 'rgba' | 'hsl' | 'hsla' 167 | 168 | // Animation easing types 169 | export type EasingFunction = 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'linear' | string 170 | 171 | // Sorting types 172 | export type SortOrder = 'asc' | 'desc' 173 | 174 | export interface SortConfig { 175 | field: string 176 | order: SortOrder 177 | } 178 | 179 | // Generic CRUD operations (removed problematic generics) 180 | 181 | // Feature flag types 182 | export type FeatureFlag = boolean | string | number | Record 183 | 184 | export interface FeatureFlagConfig { 185 | [key: string]: FeatureFlag 186 | } 187 | 188 | // Localization types 189 | export type LocaleCode = string // e.g., 'en-US', 'fr-FR', 'ja-JP' 190 | 191 | export interface LocaleConfig { 192 | code: LocaleCode 193 | name: string 194 | flag?: string 195 | rtl?: boolean 196 | } 197 | 198 | // Generic configuration 199 | export interface Config { 200 | [key: string]: any 201 | } 202 | 203 | // Version information 204 | export interface VersionInfo { 205 | version: string 206 | buildDate: string 207 | gitCommit: string 208 | environment: 'development' | 'staging' | 'production' 209 | } -------------------------------------------------------------------------------- /src/composables/useMessagePublisher.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed, watch } from 'vue' 2 | import { useToast } from 'vue-toastification' 3 | import { topicsApi } from '@/api/pubsub' 4 | 5 | interface MessageAttribute { 6 | key: string 7 | value: string 8 | } 9 | 10 | interface TemplateVariable { 11 | name: string 12 | value: string 13 | } 14 | 15 | export function useMessagePublisher() { 16 | const toast = useToast() 17 | 18 | // Form state 19 | const templateVariables = ref([{ name: '', value: '' }]) 20 | const messageAttributes = ref([{ key: '', value: '' }]) 21 | const messageData = ref('') 22 | const formatAsJson = ref(true) 23 | const isPublishing = ref(false) 24 | const jsonValidationError = ref('') 25 | 26 | // Computed properties 27 | const canPublish = computed(() => { 28 | if (!messageData.value.trim()) return false 29 | if (formatAsJson.value && jsonValidationError.value) return false 30 | return true 31 | }) 32 | 33 | // JSON validation 34 | const validateJson = () => { 35 | if (!formatAsJson.value) { 36 | jsonValidationError.value = '' 37 | return 38 | } 39 | 40 | if (!messageData.value.trim()) { 41 | jsonValidationError.value = '' 42 | return 43 | } 44 | 45 | try { 46 | JSON.parse(messageData.value) 47 | jsonValidationError.value = '' 48 | } catch { 49 | jsonValidationError.value = 'Invalid JSON format' 50 | } 51 | } 52 | 53 | // Template processing 54 | const processTemplate = (template: string): string => { 55 | let processed = template 56 | 57 | templateVariables.value.forEach(variable => { 58 | if (variable.name.trim() && variable.value.trim()) { 59 | const placeholder = `{{.${variable.name.trim()}}}` 60 | processed = processed.replace( 61 | new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), 62 | variable.value.trim() 63 | ) 64 | } 65 | }) 66 | 67 | return processed 68 | } 69 | 70 | // Message publishing 71 | const publishMessage = async (projectId: string, topicName: string) => { 72 | if (!canPublish.value) return 73 | 74 | isPublishing.value = true 75 | 76 | try { 77 | const attributes: Record = {} 78 | messageAttributes.value.forEach(attr => { 79 | if (attr.key.trim() && attr.value.trim()) { 80 | attributes[attr.key.trim()] = attr.value.trim() 81 | } 82 | }) 83 | 84 | let data = processTemplate(messageData.value) 85 | 86 | if (formatAsJson.value) { 87 | try { 88 | const parsed = JSON.parse(data) 89 | data = JSON.stringify(parsed, null, 2) 90 | } catch { 91 | // Keep original data if JSON parsing fails 92 | } 93 | } 94 | 95 | const response = await topicsApi.publishMessage(projectId, topicName, { 96 | data: btoa(data), 97 | attributes 98 | }) 99 | 100 | toast.success(`Message published successfully to topic "${topicName}"`) 101 | return response.messageIds?.[0] || 'unknown' 102 | } catch (error: any) { 103 | console.error('Error publishing message:', error) 104 | toast.error(error.message || 'Failed to publish message') 105 | throw error 106 | } finally { 107 | isPublishing.value = false 108 | } 109 | } 110 | 111 | const loadFromTemplate = (template: { data: string; attributes: Record; variables: Record }) => { 112 | messageData.value = template.data 113 | 114 | try { 115 | JSON.parse(template.data) 116 | formatAsJson.value = true 117 | } catch { 118 | formatAsJson.value = false 119 | } 120 | 121 | const variables = Object.entries(template.variables || {}) 122 | if (variables.length > 0) { 123 | templateVariables.value = variables.map(([name, value]) => ({ name, value })) 124 | } else { 125 | templateVariables.value = [{ name: '', value: '' }] 126 | } 127 | 128 | const attrs = Object.entries(template.attributes) 129 | if (attrs.length > 0) { 130 | messageAttributes.value = attrs.map(([key, value]) => ({ key, value })) 131 | } else { 132 | messageAttributes.value = [{ key: '', value: '' }] 133 | } 134 | } 135 | 136 | // Form reset 137 | const resetForm = () => { 138 | templateVariables.value = [{ name: '', value: '' }] 139 | messageAttributes.value = [{ key: '', value: '' }] 140 | messageData.value = '' 141 | formatAsJson.value = true 142 | jsonValidationError.value = '' 143 | } 144 | 145 | // Watch for JSON format changes 146 | watch([messageData, formatAsJson], () => { 147 | validateJson() 148 | }) 149 | 150 | return { 151 | // State 152 | templateVariables, 153 | messageAttributes, 154 | messageData, 155 | formatAsJson, 156 | isPublishing, 157 | jsonValidationError, 158 | 159 | // Computed 160 | canPublish, 161 | 162 | // Methods 163 | publishMessage, 164 | loadFromTemplate, 165 | resetForm, 166 | validateJson 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/composables/useDocumentUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Document utilities composable 3 | * Centralized utilities for document operations and path manipulation 4 | */ 5 | 6 | import type { FirestoreDocument } from '@/types' 7 | 8 | export const useDocumentUtils = () => { 9 | /** 10 | * Extract document ID from a Firestore document path 11 | * @param documentPath - Full document path (e.g., "projects/x/databases/(default)/documents/collection/doc-id") 12 | * @returns Document ID (last segment of the path) 13 | */ 14 | const getDocumentId = (documentPath: string): string => { 15 | return documentPath.split('/').pop() || documentPath 16 | } 17 | 18 | /** 19 | * Find document by ID in a collection of documents 20 | * @param documents - Array of FirestoreDocument objects 21 | * @param documentId - Document ID to search for 22 | * @returns Found document or undefined 23 | */ 24 | const findDocumentById = (documents: FirestoreDocument[], documentId: string): FirestoreDocument | undefined => { 25 | return documents.find(doc => getDocumentId(doc.name) === documentId) 26 | } 27 | 28 | /** 29 | * Find document by its full path name 30 | * @param documents - Array of FirestoreDocument objects 31 | * @param documentPath - Full document path to search for 32 | * @returns Found document or undefined 33 | */ 34 | const findDocumentByPath = (documents: FirestoreDocument[], documentPath: string): FirestoreDocument | undefined => { 35 | return documents.find(doc => doc.name === documentPath) 36 | } 37 | 38 | /** 39 | * Extract collection ID from document path 40 | * @param documentPath - Full document path 41 | * @returns Collection ID or empty string 42 | */ 43 | const getCollectionIdFromPath = (documentPath: string): string => { 44 | const parts = documentPath.split('/') 45 | const documentsIndex = parts.indexOf('documents') 46 | if (documentsIndex !== -1 && parts.length > documentsIndex + 1) { 47 | return parts[documentsIndex + 1] 48 | } 49 | return '' 50 | } 51 | 52 | /** 53 | * Build document path from components 54 | * @param projectId - Google Cloud project ID 55 | * @param databaseId - Firestore database ID (default: "(default)") 56 | * @param collectionId - Collection ID 57 | * @param documentId - Document ID 58 | * @returns Full document path 59 | */ 60 | const buildDocumentPath = ( 61 | projectId: string, 62 | databaseId: string = '(default)', 63 | collectionId: string, 64 | documentId: string 65 | ): string => { 66 | return `projects/${projectId}/databases/${databaseId}/documents/${collectionId}/${documentId}` 67 | } 68 | 69 | /** 70 | * Extract parent collection path from document path 71 | * @param documentPath - Full document path 72 | * @returns Parent collection path 73 | */ 74 | const getParentCollectionPath = (documentPath: string): string => { 75 | const parts = documentPath.split('/') 76 | const documentIdIndex = parts.length - 1 77 | return parts.slice(0, documentIdIndex).join('/') 78 | } 79 | 80 | /** 81 | * Check if document path is a subcollection document 82 | * @param documentPath - Full document path 83 | * @returns True if it's a subcollection document 84 | */ 85 | const isSubcollectionDocument = (documentPath: string): boolean => { 86 | const parts = documentPath.split('/') 87 | const documentsIndex = parts.indexOf('documents') 88 | // Subcollection documents have more than 3 parts after 'documents' 89 | // Normal: documents/collection/doc -> 2 parts 90 | // Subcollection: documents/collection/doc/subcollection/subdoc -> 4 parts 91 | return documentsIndex !== -1 && (parts.length - documentsIndex - 1) > 2 92 | } 93 | 94 | /** 95 | * Get breadcrumb segments from document path for navigation 96 | * @param documentPath - Full document path 97 | * @returns Array of breadcrumb segments with type and name 98 | */ 99 | const getBreadcrumbSegments = (documentPath: string): Array<{type: 'collection' | 'document', name: string}> => { 100 | const parts = documentPath.split('/') 101 | const documentsIndex = parts.indexOf('documents') 102 | 103 | if (documentsIndex === -1) return [] 104 | 105 | const pathParts = parts.slice(documentsIndex + 1) 106 | const segments: Array<{type: 'collection' | 'document', name: string}> = [] 107 | 108 | // Alternate between collection and document 109 | pathParts.forEach((part, index) => { 110 | segments.push({ 111 | type: index % 2 === 0 ? 'collection' : 'document', 112 | name: part 113 | }) 114 | }) 115 | 116 | return segments 117 | } 118 | 119 | /** 120 | * Validate document ID format 121 | * @param documentId - Document ID to validate 122 | * @returns True if valid document ID 123 | */ 124 | const isValidDocumentId = (documentId: string): boolean => { 125 | if (!documentId || typeof documentId !== 'string') return false 126 | 127 | // Firestore document ID constraints 128 | const trimmed = documentId.trim() 129 | if (trimmed.length === 0 || trimmed.length > 1500) return false 130 | 131 | // Cannot contain forward slash or control characters 132 | if (trimmed.includes('/')) return false 133 | 134 | // Check for control characters manually to avoid ESLint issues 135 | for (let i = 0; i < trimmed.length; i++) { 136 | const charCode = trimmed.charCodeAt(i) 137 | if (charCode <= 31 || charCode === 127) return false 138 | } 139 | 140 | return true 141 | } 142 | 143 | return { 144 | // Core utilities 145 | getDocumentId, 146 | findDocumentById, 147 | findDocumentByPath, 148 | 149 | // Path utilities 150 | getCollectionIdFromPath, 151 | buildDocumentPath, 152 | getParentCollectionPath, 153 | 154 | // Document analysis 155 | isSubcollectionDocument, 156 | getBreadcrumbSegments, 157 | 158 | // Validation 159 | isValidDocumentId 160 | } 161 | } 162 | --------------------------------------------------------------------------------