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 @@
+
+
+
+
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+ {{ label }}
+ {{ value }}
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+ {{ title }}
+ {{ id }}
+ {{ date }}
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
{{ description }}
+
+
+
+ {{ tag }}
+
+
+ +{{ remainingTagsCount }} {{ t.common.more }}
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
{{ t.collectionDetail.loading }}
+
+
+
+
+
{{ t.collectionDetail.errorPrefix }} {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t.collectionDetail.overview }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ coordinateSystem }}
+
+
+ {{ t.collectionDetail.coordinateLabels.west }} {{ bbox.west }}
+ {{ t.collectionDetail.coordinateLabels.south }} {{ bbox.south }}
+ {{ t.collectionDetail.coordinateLabels.east }} {{ bbox.east }}
+ {{ t.collectionDetail.coordinateLabels.north }} {{ bbox.north }}
+
+
+
+
+
+
+
+
+
+
{{ t.collectionDetail.sourceLinks }}
+
+
+
{{ t.collectionDetail.noSourceLinks }}
+
+
+
+
+
+
+
+
+ {{ copyToastMessage }}
+
+
+
+
+ {{ copyToastMessage }}
+
+
+
+
+
+
+
+
+
+ {{ t.collectionDetail.additionalProperties }}
+
+
+
+
+
+
+ {{ formatPropertyValue(item) }}
+
+
+
+ {{ JSON.stringify(prop.value, null, 2) }}
+
+
+
+ {{ formatPropertyValue(prop.value) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t.collectionDetail.items }}
+
+ ({{ itemsCountDisplay }}...)
+
+ ({{ itemsCountDisplay }})
+
+
+
{{ t.collectionDetail.loadingItems }}
+
+
+
{{ t.collectionDetail.noItems }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
{{ t.search.loadingCollections }}
+
+
+
+ {{ error }}
+
+
+
+
+
{{ t.search.noResults }}
+
{{ t.search.noResultsHint }}
+
+
+
+
+
+
+
+
+
+
+
+
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
+ }
+ }
+})