diff --git a/.gitignore b/.gitignore index 62c8935..a547bf3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,24 @@ -.idea/ \ No newline at end of file +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/.dockerignore b/ui/.dockerignore new file mode 100644 index 0000000..14346ca --- /dev/null +++ b/ui/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +.git +.gitignore +*.md +.vscode +.idea +*.log +.env.local +.env.*.local diff --git a/ui/.env b/ui/.env new file mode 100644 index 0000000..a40ad7b --- /dev/null +++ b/ui/.env @@ -0,0 +1,2 @@ +# API Configuration +VITE_API_BASE_URL=http://localhost:3000 diff --git a/ui/.gitignore b/ui/.gitignore index e69de29..7e445ac 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.vite +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/.vscode/extensions.json b/ui/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/ui/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..0a7335a --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,30 @@ +# Build stage +FROM node:22-alpine AS build + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine AS production + +# Copy built assets from build stage +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port 80 +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/ui/README.md b/ui/README.md index e69de29..e06000f 100644 --- a/ui/README.md +++ b/ui/README.md @@ -0,0 +1,386 @@ +# STAC-Atlas UI + +Vue 3 + TypeScript frontend for the STAC-Atlas project. This is a modern single-page application (SPA) that provides a user-friendly interface for searching, browsing, and exploring STAC (SpatioTemporal Asset Catalog) collections. + +## Table of Contents + +- [Overview](#overview) +- [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Local Development](#local-development) + - [Docker Deployment](#docker-deployment) +- [Environment Variables](#environment-variables) +- [How It Works](#how-it-works) +- [Design Decisions](#design-decisions) +- [Libraries & Dependencies](#libraries--dependencies) +- [Project Structure](#project-structure) +- [Documentation](#documentation) + +--- + +## Overview + +STAC-Atlas UI is a responsive web application that connects to the STAC-Atlas API to provide: + +- **Collection Search**: Full-text search across STAC collection titles, descriptions, and keywords +- **Advanced Filtering**: Filter by bounding box, temporal range, provider, license, and more +- **Interactive Maps**: Visualize collection spatial extents using MapLibre GL +- **Pagination**: Efficiently browse through large numbers of collections +- **Internationalization**: Support for English and German languages +- **CQL2 Filtering**: Advanced query support using OGC CQL2 filter expressions + +--- + +## Getting Started + +### Prerequisites + +- **Node.js** >= 18.x +- **npm** >= 9.x (or pnpm) +- **Docker** and **Docker Compose** (for containerized deployment) + +### Local Development + +```bash +# Navigate to the UI directory +cd ui + +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build + +# Preview production build +npm run preview + +# Update queryables data (providers/licenses) +npm run update-queryables +``` + +The development server runs at `http://localhost:5173/` with hot module replacement (HMR) enabled. + +### Docker Deployment + +The UI can be deployed as a standalone Docker container serving static files via Nginx. + +#### Using Docker Compose (Recommended) + +```bash +# From the ui directory +cd ui + +# Build and start the container +docker-compose up -d + +# Stop the container +docker-compose down +``` + +The UI will be available at `http://localhost:8080`. + +#### Using Docker Directly + +```bash +# Build the Docker image +docker build -t stac-atlas-ui . + +# Run the container +docker run -d -p 8080:80 --name stac-atlas-ui stac-atlas-ui + +# Stop and remove +docker stop stac-atlas-ui && docker rm stac-atlas-ui +``` + +#### Full Stack Deployment + +To run the complete STAC-Atlas stack (UI, API, Database), use the root `docker-compose.yml`: + +```bash +# From the project root +docker-compose up -d +``` + +--- + +## Environment Variables + +The UI uses Vite's environment variable system. Variables must be prefixed with `VITE_` to be exposed to the client. + +| Variable | Default | Description | +|----------|---------|-------------| +| `VITE_API_BASE_URL` | `http://localhost:3000` | Base URL of the STAC-Atlas API. Change this to point to your API server in production. | + +### Configuration + +Create a `.env` file in the `ui/` directory: + +```env +# API Configuration +VITE_API_BASE_URL=http://localhost:3000 + +# Production example +# VITE_API_BASE_URL=https://api.stac-atlas.example.com +``` + +**Note**: Environment variables are embedded at build time. For Docker deployments, you need to rebuild the image after changing `.env` values, or use runtime configuration injection. + +--- + +## How It Works + +### Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ STAC-Atlas UI │ +├─────────────────────────────────────────────────────────────┤ +│ Views (Home, CollectionDetail) │ +│ └── Components (FilterSection, SearchResults, ...) │ +│ └── Composables (useI18n, useQueryables) │ +│ └── Services (API calls) │ +│ └── Stores (Pinia state management) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ STAC-Atlas API │ + │ (REST API) │ + └─────────────────┘ +``` + +### Core Functionality + +1. **Collection Search & Filtering** + - The `FilterSection` component provides UI controls for all filter parameters + - Filters are managed centrally in the `filterStore` (Pinia store) + - Changes trigger API requests with debounced search queries + +2. **API Communication** + - The `api.ts` service handles all HTTP requests to the STAC-Atlas API + - Supports collection search parameters: `q`, `bbox`, `datetime`, `provider`, `license`, `filter` (CQL2) + - Implements RFC 7807 error response parsing + +3. **State Management** + - Pinia store (`filterStore`) maintains filter state, pagination, and loading states + - Reactive computed properties automatically format API request parameters + +4. **Internationalization** + - Custom `useI18n` composable provides English/German translations + - Language preference is persisted in localStorage + - Browser language is auto-detected on first visit + +5. **Queryables** + - Available providers and licenses are loaded from a static JSON file + - The file is generated by the `update-queryables` script which fetches from the API + - Auto-refreshes every 24 hours + +--- + +## Design Decisions + +### 1. Vue 3 Composition API + +**Decision**: Use Vue 3 with the Composition API exclusively (no Options API). + +**Rationale**: +- Better TypeScript integration with improved type inference +- More flexible code organization through composables +- Improved code reusability across components +- Better tree-shaking for smaller bundle sizes + +### 2. Vite as Build Tool + +**Decision**: Use Vite instead of Vue CLI or Webpack. + +**Rationale**: +- Significantly faster development server startup (native ES modules) +- Faster hot module replacement (HMR) +- Simpler configuration +- Better TypeScript support out of the box +- Modern build output with Rollup + +### 3. Pinia for State Management + +**Decision**: Use Pinia instead of Vuex. + +**Rationale**: +- Official Vue 3 state management library +- Better TypeScript support with full type inference +- Simpler API without mutations (just actions) +- Modular by design - each store is independent +- DevTools support built-in + +### 4. Custom i18n Implementation + +**Decision**: Implement a lightweight custom i18n solution instead of using vue-i18n. + +**Rationale**: +- Simpler implementation for a two-language application +- Smaller bundle size (no external dependency) +- Reactive language switching with Vue's reactivity system +- Full type safety for translation keys + +### 5. MapLibre GL for Maps + +**Decision**: Use MapLibre GL instead of Leaflet or other mapping libraries. + +**Rationale**: +- Open-source and free (forked from Mapbox GL before license change) +- WebGL-based rendering for smooth performance +- Better handling of vector tiles +- Modern API with good TypeScript support + +### 6. Static Queryables File + +**Decision**: Fetch filter options (providers, licenses) from a static JSON file instead of the API. + +**Rationale**: +- Reduces API load - no need to query for filter options on every page load +- Faster initial page load +- Can be cached aggressively +- Updated via a script that runs periodically + +### 7. CSS Custom Properties (CSS Variables) + +**Decision**: Use CSS custom properties for theming instead of a CSS-in-JS solution. + +**Rationale**: +- Native browser support - no runtime overhead +- Easy theme switching (future dark mode support) +- Works well with scoped component styles +- No additional library needed + +### 8. Multi-Stage Docker Build + +**Decision**: Use a multi-stage Dockerfile with Node for building and Nginx for serving. + +**Rationale**: +- Smaller final image size (Nginx Alpine is ~20MB) +- No Node.js runtime needed in production +- Efficient static file serving with Nginx +- Built-in gzip compression and caching headers + +--- + +## Libraries & Dependencies + +### Core Framework + +| Library | Version | Purpose | +|---------|---------|---------| +| **Vue** | 3.5.x | Progressive JavaScript framework for building user interfaces | +| **TypeScript** | 5.9.x | Typed superset of JavaScript for better developer experience and code quality | + +### Routing & State Management + +| Library | Version | Purpose | +|---------|---------|---------| +| **Vue Router** | 4.6.x | Official client-side router for Vue.js with history mode support | +| **Pinia** | 3.0.x | State management library for Vue with TypeScript support | + +### UI & Visualization + +| Library | Version | Purpose | +|---------|---------|---------| +| **MapLibre GL** | 5.13.x | Open-source WebGL-based library for interactive maps and spatial extent visualization | +| **Lucide Vue Next** | 0.556.x | Icon library providing consistent, customizable SVG icons throughout the UI | + +### Utilities + +| Library | Version | Purpose | +|---------|---------|---------| +| **VueUse** | 14.1.x | Collection of Vue composition utilities for common tasks (debounce, localStorage, etc.) | + +### Development Tools + +| Library | Purpose | +|---------|---------| +| **Vite** | Fast build tool with native ES modules support and HMR | +| **vue-tsc** | TypeScript type-checking for Vue single-file components | +| **@vitejs/plugin-vue** | Official Vue plugin for Vite | + +--- + +## Project Structure + +``` +ui/ +├── public/ # Static assets (served as-is) +│ └── data/ # Generated queryables JSON +├── scripts/ # Build and utility scripts +│ └── update-queryables.js # Fetches providers/licenses from API +├── src/ +│ ├── assets/ # Static assets (bundled) +│ │ └── styles/ # Global CSS architecture +│ ├── components/ # Reusable UI components +│ │ ├── BoundingBoxModal.vue # Map-based bbox selection +│ │ ├── CustomSelect.vue # Styled select dropdown +│ │ ├── FilterSection.vue # Main filter controls +│ │ ├── InfoCard.vue # Collection info display +│ │ ├── ItemCard.vue # Collection card in grid +│ │ ├── Navbar.vue # Navigation header +│ │ ├── SearchResultCard.vue # Search result item +│ │ ├── SearchResults.vue # Results grid layout +│ │ └── SearchSection.vue # Search input area +│ ├── composables/ # Shared composition functions +│ │ ├── useI18n.ts # Internationalization logic +│ │ └── useQueryables.ts # Filter options management +│ ├── i18n/ # Translation files +│ │ ├── en.ts # English translations +│ │ ├── de.ts # German translations +│ │ └── index.ts # i18n exports +│ ├── router/ # Vue Router configuration +│ │ └── index.ts # Route definitions +│ ├── services/ # API communication layer +│ │ └── api.ts # STAC-Atlas API client +│ ├── stores/ # Pinia state stores +│ │ └── filterStore.ts # Filter and pagination state +│ ├── types/ # TypeScript type definitions +│ │ └── collection.ts # STAC collection types +│ ├── views/ # Page-level components +│ │ ├── Home.vue # Main search page +│ │ └── CollectionDetail.vue # Single collection view +│ ├── App.vue # Root component +│ └── main.ts # Application entry point +├── docs/ # Internal documentation +│ ├── STRUCTURE.md # Folder structure guide +│ ├── STYLING.md # CSS architecture guide +│ └── i18n.md # Internationalization guide +├── docker-compose.yml # Docker Compose configuration +├── Dockerfile # Multi-stage Docker build +├── nginx.conf # Nginx server configuration +├── package.json # npm dependencies and scripts +├── tsconfig.json # TypeScript configuration +├── vite.config.ts # Vite build configuration +└── .env # Environment variables (not in git) +``` + +--- + +## Documentation + +- [Folder Structure Guide](./docs/STRUCTURE.md) - Detailed breakdown of project organization +- [Styling Guide](./docs/STYLING.md) - CSS architecture and component styling patterns +- [Internationalization](./docs/i18n.md) - How to add and manage translations + +--- + +## API Requirements + +The UI requires the STAC-Atlas API to be running. The API should support: + +- `GET /collections` - Search and list collections +- `GET /collections/:id` - Get single collection details +- Query parameters: `q`, `bbox`, `datetime`, `limit`, `token`, `provider`, `license`, `filter`, `filter-lang` + +See the [API documentation](../api/README.md) for full details. + +--- + +## License + +This project is part of the STAC-Atlas project. See the [LICENSE](../LICENSE) file in the project root for details. diff --git a/ui/docker-compose.yml b/ui/docker-compose.yml new file mode 100644 index 0000000..23954db --- /dev/null +++ b/ui/docker-compose.yml @@ -0,0 +1,8 @@ +services: + ui: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:80" + restart: unless-stopped diff --git a/ui/docs/STRUCTURE.md b/ui/docs/STRUCTURE.md new file mode 100644 index 0000000..e69e63d --- /dev/null +++ b/ui/docs/STRUCTURE.md @@ -0,0 +1,106 @@ +# Project Structure + +## Overview + +The UI follows a modular architecture with clear separation of concerns. + +## Folders + +### `src/assets/` + +Static assets like images, fonts, and global styles. + +**styles/** - Structured CSS architecture: + +- `base/reset.css` - CSS reset +- `base/vars.css` - CSS custom properties +- `base/base.css` - Global styles +- `main.css` - Main entry point + +### `src/components/` + +Reusable UI components used across multiple views. + +**Examples:** + +- `Button.vue` +- `SearchBar.vue` +- `MapViewer.vue` + +**Convention:** PascalCase naming, single component per file. + +### `src/composables/` + +Shared composition functions (Vue Composition API logic). + +**Examples:** + +- `useMap.ts` - Map interaction logic +- `useFetch.ts` - Data fetching utilities +- `useDebounce.ts` - Debounce helper + +**Convention:** Prefix with `use`, export as default. + +### `src/services/` + +External API calls and business logic. + +**Examples:** + +- `stacApi.ts` - STAC catalog API +- `geocoding.ts` - Geocoding service +- `api.ts` - Base API configuration + +**Convention:** Pure functions, no component logic. + +### `src/stores/` + +Pinia state management stores. + +**Examples:** + +- `catalogStore.ts` - STAC catalog state +- `mapStore.ts` - Map state and settings +- `userStore.ts` - User preferences + +**Convention:** One store per domain, use `defineStore`. + +### `src/types/` + +TypeScript type definitions and interfaces. + +**Examples:** + +- `stac.ts` - STAC specification types +- `map.ts` - Map-related types +- `api.ts` - API response types + +**Convention:** Group by domain, export interfaces. + +### `src/views/` + +Page-level components (one per route). + +**Examples:** + +- `Home.vue` +- `CatalogView.vue` +- `MapView.vue` + +**Convention:** PascalCase with `View` suffix for clarity. + +## Import Aliases + +```typescript +// Configured in vite.config.ts +import Component from '@/components/Component.vue' +import { useStore } from '@/stores/store' +import type { STACItem } from '@/types/stac' +``` + +## File Naming + +- **Components/Views:** PascalCase (`SearchBar.vue`) +- **Services/Composables:** camelCase (`stacApi.ts`, `useMap.ts`) +- **Types:** camelCase (`stac.ts`) +- **Stores:** camelCase with `Store` suffix (`catalogStore.ts`) diff --git a/ui/docs/STYLING.md b/ui/docs/STYLING.md new file mode 100644 index 0000000..355f572 --- /dev/null +++ b/ui/docs/STYLING.md @@ -0,0 +1,433 @@ +# Styling Guide + +## CSS Architecture + +The project uses a structured CSS system with custom properties for consistency. + +### File Structure + +```text +src/assets/styles/ +├── base/ +│ ├── reset.css # CSS reset +│ ├── vars.css # CSS custom properties +│ └── base.css # Global styles +├── components/ # Component-specific styles +└── main.css # Main entry (imports all) +``` + +## CSS Custom Properties + +All design tokens are defined in `base/vars.css`: + +### Colors + +```css +/* Light mode */ +--bg /* Background */ +--fg /* Foreground/text */ +--primary /* Primary brand color */ +--primary-fg /* Primary text color */ +--secondary /* Secondary color */ +--muted /* Muted background */ +--muted-fg /* Muted text */ +--border /* Border color */ +--destructive /* Error/danger color */ + +/* Semantic aliases */ +--color-text /* Main text */ +--color-text-muted /* Secondary text */ +--color-success /* Success state */ +--color-warning /* Warning state */ +--color-info /* Info state */ +``` + +**Dark mode:** Add `.dark` class to `` or ``. + +### Spacing + +```css +--spacing-xs /* 0.25rem */ +--spacing-sm /* 0.5rem */ +--spacing-md /* 1rem */ +--spacing-lg /* 1.5rem */ +--spacing-xl /* 2rem */ +--spacing-2xl /* 3rem */ +--spacing-3xl /* 4rem */ +``` + +### Typography + +```css +--font-size-xs /* 0.75rem */ +--font-size-base /* 1rem */ +--font-size-2xl /* 1.5rem */ +/* ... more sizes */ + +--font-weight-normal /* 400 */ +--font-weight-semibold /* 600 */ +--font-weight-bold /* 700 */ +``` + +### Border Radius + +```css +--radius /* Base: 0.625rem */ +--radius-sm /* Small */ +--radius-lg /* Large */ +--radius-full /* Pill shape */ +``` + +### Other + +- **Shadows:** `--shadow-sm`, `--shadow-md`, `--shadow-lg` +- **Transitions:** `--transition-fast`, `--transition-base`, `--transition-slow` +- **Z-index:** `--z-index-modal`, `--z-index-dropdown`, etc. + +## Component Styling + +Each component gets its own dedicated CSS file in `src/assets/styles/components/`. + +### Naming Convention + +Component: `src/components/SearchBar.vue` +Stylesheet: `src/assets/styles/components/search-bar.css` + +Use kebab-case for CSS filenames matching the component name. + +### Setup Steps + +1. **Create the component CSS file:** + +```css +/* src/assets/styles/components/button.css */ +.btn { + padding: var(--spacing-sm) var(--spacing-lg); + background: var(--primary); + color: var(--primary-fg); + border-radius: var(--radius); + font-weight: var(--font-weight-semibold); + transition: background-color var(--transition-fast); + cursor: pointer; +} + +.btn:hover { + opacity: 0.9; +} + +.btn-primary { + background: var(--primary); + color: var(--primary-fg); +} + +.btn-secondary { + background: var(--secondary); + color: var(--secondary-fg); +} + +.btn-destructive { + background: var(--destructive); + color: var(--destructive-fg); +} +``` + +1. **Import in `main.css`:** + +```css +/* src/assets/styles/main.css */ +@import './base/reset.css'; +@import './base/vars.css'; +@import './base/base.css'; + +/* Component styles */ +@import './components/button.css'; +@import './components/search-bar.css'; +@import './components/card.css'; +``` + +1. **Use classes in component:** + +```vue + + + + +``` + +**No ` diff --git a/ui/src/components/CustomSelect.vue b/ui/src/components/CustomSelect.vue new file mode 100644 index 0000000..61780af --- /dev/null +++ b/ui/src/components/CustomSelect.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/ui/src/components/FilterSection.vue b/ui/src/components/FilterSection.vue new file mode 100644 index 0000000..8d5c67d --- /dev/null +++ b/ui/src/components/FilterSection.vue @@ -0,0 +1,313 @@ + + + \ No newline at end of file diff --git a/ui/src/components/InfoCard.vue b/ui/src/components/InfoCard.vue new file mode 100644 index 0000000..2c75aac --- /dev/null +++ b/ui/src/components/InfoCard.vue @@ -0,0 +1,36 @@ + + + + + \ No newline at end of file diff --git a/ui/src/components/ItemCard.vue b/ui/src/components/ItemCard.vue new file mode 100644 index 0000000..d31ee0d --- /dev/null +++ b/ui/src/components/ItemCard.vue @@ -0,0 +1,41 @@ + + + + + \ No newline at end of file diff --git a/ui/src/components/Navbar.vue b/ui/src/components/Navbar.vue new file mode 100644 index 0000000..7e8264e --- /dev/null +++ b/ui/src/components/Navbar.vue @@ -0,0 +1,64 @@ + + + diff --git a/ui/src/components/SearchResultCard.vue b/ui/src/components/SearchResultCard.vue new file mode 100644 index 0000000..5aa56e2 --- /dev/null +++ b/ui/src/components/SearchResultCard.vue @@ -0,0 +1,148 @@ + + + + + \ No newline at end of file diff --git a/ui/src/components/SearchResults.vue b/ui/src/components/SearchResults.vue new file mode 100644 index 0000000..1e21581 --- /dev/null +++ b/ui/src/components/SearchResults.vue @@ -0,0 +1,20 @@ + + + diff --git a/ui/src/components/SearchSection.vue b/ui/src/components/SearchSection.vue new file mode 100644 index 0000000..702a5c6 --- /dev/null +++ b/ui/src/components/SearchSection.vue @@ -0,0 +1,36 @@ + + + diff --git a/ui/src/composables/.gitkeep b/ui/src/composables/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/composables/useI18n.ts b/ui/src/composables/useI18n.ts new file mode 100644 index 0000000..a16dfcc --- /dev/null +++ b/ui/src/composables/useI18n.ts @@ -0,0 +1,71 @@ +import { ref, computed, readonly } from 'vue' +import { messages, type Locale, type Messages } from '@/i18n' + +// Global reactive state for current locale +const currentLocale = ref('en') + +// Helper to get nested value from object by dot-notation path +function getNestedValue(obj: Record, path: string): string { + const keys = path.split('.') + let result: unknown = obj + + for (const key of keys) { + if (result && typeof result === 'object' && key in result) { + result = (result as Record)[key] + } else { + return path // Return the key if path not found + } + } + + return typeof result === 'string' ? result : path +} + +export function useI18n() { + // Current translations based on locale + const t = computed(() => messages[currentLocale.value] as Messages) + + // Translation function with dot notation support + // Usage: $t('navbar.title') or $t('filters.regions.europe') + const $t = (key: string): string => { + return getNestedValue(t.value as unknown as Record, key) + } + + // Set locale + const setLocale = (locale: Locale) => { + currentLocale.value = locale + // Persist to localStorage + localStorage.setItem('stac-atlas-locale', locale) + // Update HTML lang attribute + document.documentElement.lang = locale + } + + // Toggle between languages + const toggleLocale = () => { + setLocale(currentLocale.value === 'en' ? 'de' : 'en') + } + + // Initialize locale from localStorage or browser + const initLocale = () => { + const stored = localStorage.getItem('stac-atlas-locale') as Locale | null + if (stored && (stored === 'en' || stored === 'de')) { + setLocale(stored) + } else { + // Try to detect from browser + const browserLang = navigator.language.split('-')[0] + if (browserLang === 'de') { + setLocale('de') + } else { + setLocale('en') + } + } + } + + return { + locale: readonly(currentLocale), + t, + $t, + setLocale, + toggleLocale, + initLocale + } +} diff --git a/ui/src/composables/useQueryables.ts b/ui/src/composables/useQueryables.ts new file mode 100644 index 0000000..55b6c9c --- /dev/null +++ b/ui/src/composables/useQueryables.ts @@ -0,0 +1,118 @@ +import { ref, onMounted, watch } from 'vue' + +export interface QueryablesData { + providers: string[] + licenses: string[] + lastUpdated: string | null +} + +const STATIC_FILE_URL = '/data/queryables.json' +const REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours + +// Shared state across components +const queryables = ref({ + providers: [], + licenses: [], + lastUpdated: null +}) +const loading = ref(false) +const error = ref(null) +let refreshInterval: ReturnType | null = null +let isInitialized = false + +/** + * Load queryables from the static JSON file + * This file is updated daily by the update-queryables script + */ +async function loadQueryables(): Promise { + loading.value = true + error.value = null + + try { + // Add cache-busting parameter to ensure we get the latest version + const cacheBuster = `?t=${Date.now()}` + const response = await fetch(`${STATIC_FILE_URL}${cacheBuster}`) + + if (!response.ok) { + throw new Error(`Failed to load queryables: ${response.statusText}`) + } + + const data: QueryablesData = await response.json() + queryables.value = data + + console.log(`[Queryables] Loaded ${data.providers.length} providers and ${data.licenses.length} licenses (updated: ${data.lastUpdated})`) + + } catch (err) { + error.value = 'Failed to load filter options' + console.error('Error loading queryables:', err) + } finally { + loading.value = false + } +} + +/** + * Start auto-refresh interval (daily check) + */ +function startAutoRefresh(): void { + if (refreshInterval) return + + refreshInterval = setInterval(() => { + loadQueryables() + }, REFRESH_INTERVAL_MS) +} + +/** + * Stop auto-refresh interval + */ +function stopAutoRefresh(): void { + if (refreshInterval) { + clearInterval(refreshInterval) + refreshInterval = null + } +} + +/** + * Composable for accessing queryables (providers and licenses) + * Data is loaded from a static JSON file that is updated daily by update-queryables script + * The file is refreshed every 24 hours to check for updates + */ +export function useQueryables() { + // Convert to select options format (computed from queryables) + const providerOptions = ref>([]) + const licenseOptions = ref>([]) + + // Update options when queryables change + const updateOptions = () => { + providerOptions.value = [ + { value: '', label: 'All Providers' }, + ...queryables.value.providers.map(p => ({ value: p, label: p })) + ] + licenseOptions.value = [ + { value: '', label: 'All Licenses' }, + ...queryables.value.licenses.map(l => ({ value: l, label: l })) + ] + } + + // Watch for changes and update options reactively + watch(queryables, updateOptions, { deep: true, immediate: true }) + + onMounted(async () => { + // Only initialize once across all component instances + if (!isInitialized) { + isInitialized = true + await loadQueryables() + startAutoRefresh() + } + }) + + return { + queryables, + providerOptions, + licenseOptions, + loading, + error, + refresh: loadQueryables, + updateOptions, + stopAutoRefresh + } +} diff --git a/ui/src/i18n/de.ts b/ui/src/i18n/de.ts new file mode 100644 index 0000000..953dc0d --- /dev/null +++ b/ui/src/i18n/de.ts @@ -0,0 +1,170 @@ +export default { + // Navbar + navbar: { + title: 'STAC Atlas', + subtitle: 'Geodaten-Explorer', + logoAlt: 'STAC Atlas Logo', + switchToGerman: 'Zu Deutsch wechseln', + switchToEnglish: 'Zu Englisch wechseln', + switchToLightMode: 'Zum hellen Modus wechseln', + switchToDarkMode: 'Zum dunklen Modus wechseln', + information: 'Information' + }, + + // Common + common: { + loading: 'Lädt...', + error: 'Fehler', + all: 'Alle', + save: 'Speichern', + cancel: 'Abbrechen', + clear: 'Löschen', + reset: 'Zurücksetzen', + apply: 'Anwenden', + go: 'Los', + of: 'von', + total: 'gesamt', + more: 'mehr', + unknown: 'Unbekannt', + notAvailable: 'k.A.', + copyToClipboard: 'In Zwischenablage kopieren', + copiedToClipboard: 'In Zwischenablage kopiert!', + failedToCopy: 'Kopieren fehlgeschlagen', + openingLink: 'Link wird geöffnet...', + openingWebsite: 'Website wird geöffnet...' + }, + + // Filter Section + filters: { + spatialFilter: 'Räumlicher Filter', + drawBoundingBox: 'Begrenzungsrahmen zeichnen', + selectRegion: 'Region auswählen', + selectARegion: 'Region auswählen', + west: 'West', + east: 'Ost', + south: 'Süd', + north: 'Nord', + + temporalFilter: 'Zeitlicher Filter', + startDate: 'Startdatum', + endDate: 'Enddatum', + + provider: 'Anbieter', + allProviders: 'Alle Anbieter', + + license: 'Lizenz', + allLicenses: 'Alle Lizenzen', + + collectionStatus: 'Collectionstatus', + activeStatus: 'Aktivstatus', + active: 'Aktiv', + inactive: 'Inaktiv', + + apiStatus: 'API Status', + accessibleViaApi: 'Über API zugänglich', + staticCatalog: 'Statischer Katalog', + + cql2Filter: 'CQL2 Filter', + cql2Placeholder: 'Text: title LIKE \'%Sentinel%\'\nJSON: {"op":"=","args":[{"property":"license"},"CC-BY-4.0"]}', + formattingJson: 'JSON wird formatiert...', + cql2Hint: 'CQL2-Text oder CQL2-JSON', + + applyFilters: 'Filter anwenden', + + // Regions + regions: { + europe: 'Europa', + asia: 'Asien', + africa: 'Afrika', + americas: 'Amerika', + oceania: 'Ozeanien', + global: 'Global' + } + }, + + // Bounding Box Modal + bboxModal: { + title: 'Begrenzungsrahmen zeichnen', + instructions: 'Klicken und ziehen Sie auf der Karte, um einen Begrenzungsrahmen zu zeichnen, oder geben Sie die Koordinaten unten manuell ein.', + minLongitude: 'Min. Längengrad (West)', + maxLongitude: 'Max. Längengrad (Ost)', + minLatitude: 'Min. Breitengrad (Süd)', + maxLatitude: 'Max. Breitengrad (Nord)' + }, + + // Search + search: { + title: 'Suchergebnisse', + collections: 'Collections', + placeholder: 'Collections nach Titel, Beschreibung, Schlüsselwörtern durchsuchen...', + noResults: 'Keine Ergebnisse gefunden.', + noResultsHint: 'Passen Sie Ihre Abfrage- oder Filterparameter an.', + loadingCollections: 'Collections werden geladen...' + }, + + // Collection Card + collectionCard: { + untitledCollection: 'Unbenannte Collection', + noDescription: 'Keine Beschreibung verfügbar', + unknownProvider: 'Unbekannter Anbieter', + noPlatformData: 'Keine Plattformdaten', + viewDetails: 'Details anzeigen', + source: 'Quelle' + }, + + // Collection Detail + collectionDetail: { + loading: 'Collection-Details werden geladen...', + errorPrefix: 'Fehler:', + + // Sections + overview: 'Übersicht', + metadata: 'Metadaten', + items: 'Elemente', + additionalProperties: 'Zusätzliche Eigenschaften', + + // Source + viewSource: 'Quelle anzeigen', + sourceLinks: 'Quell-Links', + noSourceLinks: 'Keine Quell-Links verfügbar', + + // Providers + providers: 'Anbieter', + providerInfo: 'Anbieterinformationen', + noProviderInfo: 'Keine Anbieterinformationen verfügbar', + providerRoles: { + producer: 'Produzent', + licensor: 'Lizenzgeber', + processor: 'Verarbeiter', + host: 'Host' + }, + + // Items + loadingItems: 'Elemente werden von der Quelle geladen...', + noItems: 'Keine Elemente verfügbar', + + // Coordinates + coordinateLabels: { + west: 'W:', + south: 'S:', + east: 'O:', + north: 'N:' + }, + + // Metadata labels + collectionId: 'Collection ID', + stacVersion: 'STAC Version', + keywords: 'Schlüsselwörter', + + // Default values + untitledCollection: 'Unbenannte Collection', + unknownProvider: 'Unbekannter Anbieter', + unknownLicense: 'Unbekannt', + noDescription: 'Keine Beschreibung verfügbar' + }, + + // Pagination + pagination: { + goToPage: 'Zur Seite' + } +} diff --git a/ui/src/i18n/en.ts b/ui/src/i18n/en.ts new file mode 100644 index 0000000..ab0d801 --- /dev/null +++ b/ui/src/i18n/en.ts @@ -0,0 +1,170 @@ +export default { + // Navbar + navbar: { + title: 'STAC Atlas', + subtitle: 'Geospatial Data Explorer', + logoAlt: 'STAC Atlas Logo', + switchToGerman: 'Switch to German', + switchToEnglish: 'Switch to English', + switchToLightMode: 'Switch to light mode', + switchToDarkMode: 'Switch to dark mode', + information: 'Information' + }, + + // Common + common: { + loading: 'Loading...', + error: 'Error', + all: 'All', + save: 'Save', + cancel: 'Cancel', + clear: 'Clear', + reset: 'Reset', + apply: 'Apply', + go: 'Go', + of: 'of', + total: 'total', + more: 'more', + unknown: 'Unknown', + notAvailable: 'N/A', + copyToClipboard: 'Copy to clipboard', + copiedToClipboard: 'Copied to clipboard!', + failedToCopy: 'Failed to copy', + openingLink: 'Opening link...', + openingWebsite: 'Opening website...' + }, + + // Filter Section + filters: { + spatialFilter: 'Spatial Filter', + drawBoundingBox: 'Draw Bounding Box', + selectRegion: 'Select Region', + selectARegion: 'Select a region', + west: 'West', + east: 'East', + south: 'South', + north: 'North', + + temporalFilter: 'Temporal Filter', + startDate: 'Start Date', + endDate: 'End Date', + + provider: 'Provider', + allProviders: 'All Providers', + + license: 'License', + allLicenses: 'All Licenses', + + collectionStatus: 'Collection Status', + activeStatus: 'Active Status', + active: 'Active', + inactive: 'Inactive', + + apiStatus: 'API Status', + accessibleViaApi: 'Accessible via API', + staticCatalog: 'Static Catalog', + + cql2Filter: 'CQL2 Filter', + cql2Placeholder: 'Text: title LIKE \'%Sentinel%\'\nJSON: {"op":"=","args":[{"property":"license"},"CC-BY-4.0"]}', + formattingJson: 'Formatting JSON...', + cql2Hint: 'CQL2-Text or CQL2-JSON', + + applyFilters: 'Apply Filters', + + // Regions + regions: { + europe: 'Europe', + asia: 'Asia', + africa: 'Africa', + americas: 'Americas', + oceania: 'Oceania', + global: 'Global' + } + }, + + // Bounding Box Modal + bboxModal: { + title: 'Draw Bounding Box', + instructions: 'Click and drag on the map to draw a bounding box, or enter coordinates manually below.', + minLongitude: 'Min Longitude (West)', + maxLongitude: 'Max Longitude (East)', + minLatitude: 'Min Latitude (South)', + maxLatitude: 'Max Latitude (North)' + }, + + // Search + search: { + title: 'Search Results', + collections: 'collections', + placeholder: 'Search collections by title, description, keywords...', + noResults: 'No results found.', + noResultsHint: 'Adjust your query or filter parameters.', + loadingCollections: 'Loading collections...' + }, + + // Collection Card + collectionCard: { + untitledCollection: 'Untitled Collection', + noDescription: 'No description available', + unknownProvider: 'Unknown Provider', + noPlatformData: 'No platform data', + viewDetails: 'View Details', + source: 'Source' + }, + + // Collection Detail + collectionDetail: { + loading: 'Loading collection details...', + errorPrefix: 'Error:', + + // Sections + overview: 'Overview', + metadata: 'Metadata', + items: 'Items', + additionalProperties: 'Additional Properties', + + // Source + viewSource: 'View Source', + sourceLinks: 'Source Links', + noSourceLinks: 'No source links available', + + // Providers + providers: 'Providers', + providerInfo: 'Provider Information', + noProviderInfo: 'No provider information available', + providerRoles: { + producer: 'Producer', + licensor: 'Licensor', + processor: 'Processor', + host: 'Host' + }, + + // Items + loadingItems: 'Loading items from source...', + noItems: 'No items available', + + // Coordinates + coordinateLabels: { + west: 'W:', + south: 'S:', + east: 'E:', + north: 'N:' + }, + + // Metadata labels + collectionId: 'Collection ID', + stacVersion: 'STAC Version', + keywords: 'Keywords', + + // Default values + untitledCollection: 'Untitled Collection', + unknownProvider: 'Unknown Provider', + unknownLicense: 'Unknown', + noDescription: 'No description available' + }, + + // Pagination + pagination: { + goToPage: 'Go to page' + } +} diff --git a/ui/src/i18n/index.ts b/ui/src/i18n/index.ts new file mode 100644 index 0000000..14d2f57 --- /dev/null +++ b/ui/src/i18n/index.ts @@ -0,0 +1,11 @@ +import en from './en' +import de from './de' + +export type Locale = 'en' | 'de' + +export const messages = { + en, + de +} + +export type Messages = typeof en diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..4c4724c --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import './assets/styles/main.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(router) +app.mount('#app') diff --git a/ui/src/router/index.ts b/ui/src/router/index.ts new file mode 100644 index 0000000..8ec02c0 --- /dev/null +++ b/ui/src/router/index.ts @@ -0,0 +1,22 @@ +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'Home', + component: () => import('@/views/Home.vue') + }, + { + path: '/collections/:id', + name: 'CollectionDetail', + component: () => import('@/views/CollectionDetail.vue') + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +export default router diff --git a/ui/src/services/.gitkeep b/ui/src/services/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/services/api.ts b/ui/src/services/api.ts new file mode 100644 index 0000000..950ffe8 --- /dev/null +++ b/ui/src/services/api.ts @@ -0,0 +1,97 @@ +import type { CollectionsResponse, Collection, APIError } from '@/types/collection' + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000' + +/** + * Collection search parameters matching the STAC Atlas API + * See: api/docs/collection-search-parameters.md + */ +export interface CollectionSearchParams { + /** Free-text search query (max 500 chars) - searches title, description, keywords */ + q?: string + /** Bounding box filter: minX,minY,maxX,maxY */ + bbox?: string + /** ISO8601 datetime or interval (e.g., "2020-01-01/2021-12-31") */ + datetime?: string + /** Result limit (default: 10, max: 10000) */ + limit?: number + /** Sort by field: +/-field (title, id, license, created, updated) */ + sortby?: string + /** Pagination token (offset, default: 0) */ + token?: number + /** Filter by provider name */ + provider?: string + /** Filter by license identifier */ + license?: string + /** Filter by active status (true/false) */ + active?: boolean + /** Filter by API status (true/false) */ + api?: boolean + /** CQL2 filter expression for advanced queries */ + filter?: string + /** Filter language: 'cql2-text' or 'cql2-json' */ + 'filter-lang'?: 'cql2-text' | 'cql2-json' +} + +/** + * Parse RFC 7807 error response + */ +async function parseErrorResponse(response: Response): Promise { + try { + const errorData: APIError = await response.json() + // RFC 7807 uses 'detail', with 'description' as backwards compatibility alias + return errorData.detail || errorData.description || errorData.title || `Request failed: ${response.statusText}` + } catch { + return `Request failed: ${response.statusText}` + } +} + +export const api = { + /** + * Fetch collections with optional filtering and pagination + * Supports: q, bbox, datetime, limit, sortby, token, provider, license, filter, filter-lang + * + * Note: API has rate limit of 1000 requests per 15 minutes + */ + async getCollections(params?: CollectionSearchParams): Promise { + const queryParams = new URLSearchParams() + + if (params) { + // Auto-detect filter-lang if filter is provided but filter-lang is not + if (params.filter && !params['filter-lang']) { + params['filter-lang'] = params.filter.trim().startsWith('{') ? 'cql2-json' : 'cql2-text' + } + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, value.toString()) + } + }) + } + + const url = `${API_BASE_URL}/collections${queryParams.toString() ? `?${queryParams.toString()}` : ''}` + + const response = await fetch(url) + + if (!response.ok) { + throw new Error(await parseErrorResponse(response)) + } + + return response.json() + }, + + /** + * Fetch a single collection by ID + */ + async getCollection(id: string | number): Promise { + const url = `${API_BASE_URL}/collections/${id}` + + const response = await fetch(url) + + if (!response.ok) { + throw new Error(await parseErrorResponse(response)) + } + + return response.json() + } +} diff --git a/ui/src/stores/.gitkeep b/ui/src/stores/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/stores/filterStore.ts b/ui/src/stores/filterStore.ts new file mode 100644 index 0000000..c42bab5 --- /dev/null +++ b/ui/src/stores/filterStore.ts @@ -0,0 +1,169 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +export interface FilterState { + bbox?: string + datetime?: string + provider?: string + license?: string + active?: boolean + api?: boolean + q?: string + filter?: string + 'filter-lang'?: 'cql2-text' | 'cql2-json' +} + +export const useFilterStore = defineStore('filters', () => { + // Filter values + const selectedRegion = ref('') + const drawnBbox = ref('') + const startDate = ref('') + const endDate = ref('') + const selectedProvider = ref('') + const selectedLicense = ref('') + const activeFilter = ref('') // '', 'true', 'false' - default to all + const apiFilter = ref('') // '', 'true', 'false' + const searchQuery = ref('') + const cql2Filter = ref('') + + // Pagination state + const currentPage = ref(1) + const itemsPerPage = ref(48) + const totalCollections = ref(0) + + // UI state + const loading = ref(false) + const error = ref(null) + + // Computed: active bbox (drawn takes priority over region) + const activeBbox = computed(() => drawnBbox.value || selectedRegion.value || undefined) + + // Computed: datetime interval for API + const datetime = computed(() => { + if (!startDate.value && !endDate.value) return undefined + + const start = startDate.value || '..' + const end = endDate.value || '..' + + if (start === '..' && end === '..') return undefined + + return `${start}/${end}` + }) + + // Computed: detect CQL2 filter language (JSON if starts with {, otherwise text) + const cql2FilterLang = computed<'cql2-text' | 'cql2-json' | undefined>(() => { + const trimmed = cql2Filter.value.trim() + if (!trimmed) return undefined + return trimmed.startsWith('{') ? 'cql2-json' : 'cql2-text' + }) + + // Computed: all active filters for API request + const activeFilters = computed(() => ({ + bbox: activeBbox.value, + datetime: datetime.value, + provider: selectedProvider.value || undefined, + license: selectedLicense.value || undefined, + active: activeFilter.value ? activeFilter.value === 'true' : undefined, + api: apiFilter.value ? apiFilter.value === 'true' : undefined, + q: searchQuery.value.trim() || undefined, + filter: cql2Filter.value.trim() || undefined, + 'filter-lang': cql2FilterLang.value + })) + + // Computed: formatted bbox for display + const formattedBbox = computed(() => { + if (!drawnBbox.value) return { minLon: '', minLat: '', maxLon: '', maxLat: '' } + const parts = drawnBbox.value.split(',').map(Number) + return { + minLon: parts[0]?.toFixed(4) ?? '', + minLat: parts[1]?.toFixed(4) ?? '', + maxLon: parts[2]?.toFixed(4) ?? '', + maxLat: parts[3]?.toFixed(4) ?? '' + } + }) + + // Computed: total pages + const totalPages = computed(() => Math.ceil(totalCollections.value / itemsPerPage.value)) + + // Actions + function setDrawnBbox(bbox: string) { + drawnBbox.value = bbox + selectedRegion.value = '' // Clear region when custom bbox is set + } + + function clearDrawnBbox() { + drawnBbox.value = '' + } + + function resetFilters() { + selectedRegion.value = '' + drawnBbox.value = '' + startDate.value = '' + endDate.value = '' + selectedProvider.value = '' + selectedLicense.value = '' + activeFilter.value = 'true' // Reset to active by default + apiFilter.value = '' + searchQuery.value = '' + cql2Filter.value = '' + currentPage.value = 1 + } + + function setPage(page: number) { + if (page >= 1 && page <= totalPages.value) { + currentPage.value = page + } + } + + function resetPagination() { + currentPage.value = 1 + } + + function setLoading(value: boolean) { + loading.value = value + } + + function setError(message: string | null) { + error.value = message + } + + function setTotalCollections(count: number) { + totalCollections.value = count + } + + return { + // State + selectedRegion, + drawnBbox, + startDate, + endDate, + selectedProvider, + selectedLicense, + activeFilter, + apiFilter, + searchQuery, + cql2Filter, + currentPage, + itemsPerPage, + totalCollections, + loading, + error, + + // Computed + activeBbox, + datetime, + activeFilters, + formattedBbox, + totalPages, + + // Actions + setDrawnBbox, + clearDrawnBbox, + resetFilters, + setPage, + resetPagination, + setLoading, + setError, + setTotalCollections + } +}) diff --git a/ui/src/types/.gitkeep b/ui/src/types/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/types/collection.ts b/ui/src/types/collection.ts new file mode 100644 index 0000000..49077ec --- /dev/null +++ b/ui/src/types/collection.ts @@ -0,0 +1,82 @@ +/** + * STAC-conformant Collection structure + * The API now returns fully STAC-conformant collections without full_json wrapper + * See: https://github.com/radiantearth/stac-spec/blob/master/collection-spec/collection-spec.md + */ + +export interface STACLink { + rel: string + href: string + type?: string + title?: string +} + +export interface STACProvider { + name: string + description?: string + roles?: string[] + url?: string +} + +export interface STACExtent { + spatial: { + bbox: number[][] + } + temporal: { + interval: (string | null)[][] + } +} + +// STAC-conformant Collection structure returned by the API +export interface Collection { + // Required STAC fields + type: 'Collection' + id: string + stac_version: string + description: string + license: string + extent: STACExtent + links: STACLink[] + + // Optional STAC fields + title?: string + stac_extensions?: string[] + keywords?: string[] + providers?: STACProvider[] + summaries?: Record + assets?: Record + + // Source links from original STAC catalog (items stored on AWS) + source_links?: STACLink[] + source_url?: string + source_id?: string + + // Full-text search rank (only present when q parameter is used) + rank?: number +} + +export interface CollectionsResponse { + type?: string // "FeatureCollection" + collections: Collection[] + links: STACLink[] + context?: { + returned: number + matched: number + limit: number + } +} + +/** + * RFC 7807 Problem Details error response + * See: https://datatracker.ietf.org/doc/html/rfc7807 + */ +export interface APIError { + type: string + title: string + status: number + detail: string + instance?: string + requestId?: string + code?: string // backwards compatibility + description?: string // alias for detail +} diff --git a/ui/src/views/.gitkeep b/ui/src/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/views/CollectionDetail.vue b/ui/src/views/CollectionDetail.vue new file mode 100644 index 0000000..adb3411 --- /dev/null +++ b/ui/src/views/CollectionDetail.vue @@ -0,0 +1,1269 @@ + + + + + diff --git a/ui/src/views/Home.vue b/ui/src/views/Home.vue new file mode 100644 index 0000000..83ed183 --- /dev/null +++ b/ui/src/views/Home.vue @@ -0,0 +1,231 @@ + + + + + diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json new file mode 100644 index 0000000..9458c85 --- /dev/null +++ b/ui/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + + /* Path Mapping */ + "paths": { + "@/*": ["./src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/ui/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..56610a3 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,19 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + watch: { + usePolling: true, // Required for Docker on Windows/OneDrive + interval: 1000 + } + } +})