diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..0376189
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,2 @@
+# Frontend Environment Variables
+VITE_API_URL=/api
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..e8eac74
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Oliver Metadata Tool v4.0
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..f9bbe0f
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "oliver-metadata-frontend",
+ "version": "4.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.21.0",
+ "zustand": "^4.4.7",
+ "axios": "^1.6.5",
+ "react-dropzone": "^14.2.3",
+ "react-hot-toast": "^2.4.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.48",
+ "@types/react-dom": "^18.2.18",
+ "@vitejs/plugin-react": "^4.2.1",
+ "vite": "^5.0.11",
+ "typescript": "^5.3.3",
+ "tailwindcss": "^3.4.1",
+ "autoprefixer": "^10.4.17",
+ "postcss": "^8.4.33"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
new file mode 100644
index 0000000..48f4420
--- /dev/null
+++ b/frontend/src/App.tsx
@@ -0,0 +1,34 @@
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import { Toaster } from 'react-hot-toast';
+import { useAuthStore } from './store/authStore';
+import LoginPage from './pages/LoginPage';
+import DashboardPage from './pages/DashboardPage';
+
+function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
+ return isAuthenticated ? <>{children}> : ;
+}
+
+function App() {
+ return (
+ <>
+
+
+ } />
+
+
+
+ }
+ />
+ } />
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..8970c1b
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,22 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+ font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+ monospace;
+}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
new file mode 100644
index 0000000..2339d59
--- /dev/null
+++ b/frontend/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App';
+import './index.css';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx
new file mode 100644
index 0000000..3ce10bf
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.tsx
@@ -0,0 +1,78 @@
+import { useState } from 'react';
+import { useAuthStore } from '../store/authStore';
+import { useFileStore } from '../store/fileStore';
+import { authService } from '../services/api';
+import { useNavigate } from 'react-router-dom';
+import FileUploadZone from '../components/files/FileUploadZone';
+import FileList from '../components/files/FileList';
+import toast from 'react-hot-toast';
+
+export default function DashboardPage() {
+ const user = useAuthStore((state) => state.user);
+ const logout = useAuthStore((state) => state.logout);
+ const files = useFileStore((state) => state.files);
+ const sessionId = useFileStore((state) => state.sessionId);
+ const navigate = useNavigate();
+
+ const handleLogout = async () => {
+ try {
+ await authService.logout();
+ logout();
+ navigate('/login');
+ toast.success('Logged out successfully');
+ } catch (error) {
+ console.error('Logout error:', error);
+ logout();
+ navigate('/login');
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+ 🎯 Oliver Metadata Tool
+
+
+ Universal File Metadata Management
+
+
+
+
+ Welcome, {user?.username}
+
+
+
+
+
+
+
+ {/* Main Content */}
+
+
+ {/* Upload Section */}
+ {!sessionId &&
}
+
+ {/* File List */}
+ {files.length > 0 &&
}
+
+ {/* Empty State */}
+ {!sessionId && files.length === 0 && (
+
+
Upload files to get started
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..b5af58f
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -0,0 +1,85 @@
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useAuthStore } from '../store/authStore';
+import { authService } from '../services/api';
+import toast from 'react-hot-toast';
+
+export default function LoginPage() {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [isLoading, setIsLoading] = useState(false);
+ const navigate = useNavigate();
+ const login = useAuthStore((state) => state.login);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsLoading(true);
+
+ try {
+ const response = await authService.login(username, password);
+ login(response.user, response.access_token, response.refresh_token);
+ toast.success('Login successful!');
+ navigate('/');
+ } catch (error: any) {
+ toast.error(error.response?.data?.detail || 'Login failed');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+ 🎯 Oliver Metadata Tool
+
+
v4.0 - FastAPI Edition
+
+
+
+
+
+
Test credentials: test / test123
+
(Register first if needed)
+
+
+
+ );
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
new file mode 100644
index 0000000..6f3ca04
--- /dev/null
+++ b/frontend/src/services/api.ts
@@ -0,0 +1,177 @@
+/**
+ * API Client with Auth Interceptors
+ * Handles JWT token management and automatic refresh
+ */
+
+import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
+
+const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
+
+// Create axios instance
+export const api = axios.create({
+ baseURL: API_BASE_URL,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+// Request interceptor - add JWT token
+api.interceptors.request.use(
+ (config: InternalAxiosRequestConfig) => {
+ const token = localStorage.getItem('access_token');
+ if (token && config.headers) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ return config;
+ },
+ (error) => Promise.reject(error)
+);
+
+// Response interceptor - handle 401 and refresh token
+api.interceptors.response.use(
+ (response) => response,
+ async (error: AxiosError) => {
+ const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
+
+ // If 401 and not already retrying
+ if (error.response?.status === 401 && !originalRequest._retry) {
+ originalRequest._retry = true;
+
+ const refreshToken = localStorage.getItem('refresh_token');
+ if (!refreshToken) {
+ // No refresh token, redirect to login
+ localStorage.clear();
+ window.location.href = '/login';
+ return Promise.reject(error);
+ }
+
+ try {
+ // Refresh the token
+ const response = await axios.post(`${API_BASE_URL}/auth/token/refresh`, {
+ refresh_token: refreshToken,
+ });
+
+ const { access_token, refresh_token: newRefreshToken } = response.data;
+
+ // Save new tokens
+ localStorage.setItem('access_token', access_token);
+ localStorage.setItem('refresh_token', newRefreshToken);
+
+ // Retry original request with new token
+ if (originalRequest.headers) {
+ originalRequest.headers.Authorization = `Bearer ${access_token}`;
+ }
+ return api(originalRequest);
+ } catch (refreshError) {
+ // Refresh failed, redirect to login
+ localStorage.clear();
+ window.location.href = '/login';
+ return Promise.reject(refreshError);
+ }
+ }
+
+ return Promise.reject(error);
+ }
+);
+
+// API Services
+export const authService = {
+ login: async (username: string, password: string) => {
+ const response = await api.post('/auth/login', { username, password });
+ return response.data;
+ },
+
+ register: async (username: string, password: string) => {
+ const response = await api.post('/auth/register', { username, password });
+ return response.data;
+ },
+
+ logout: async () => {
+ const response = await api.post('/auth/logout', {});
+ return response.data;
+ },
+
+ getCurrentUser: async () => {
+ const response = await api.get('/auth/me');
+ return response.data;
+ },
+};
+
+export const fileService = {
+ uploadFiles: async (files: File[], metadataSource: string, importSessionId?: string) => {
+ const formData = new FormData();
+ files.forEach((file) => formData.append('files', file));
+ formData.append('metadata_source', metadataSource);
+ if (importSessionId) {
+ formData.append('import_session_id', importSessionId);
+ }
+
+ const response = await api.post('/files/upload', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ });
+ return response.data;
+ },
+
+ downloadFile: async (fileId: string) => {
+ const response = await api.get(`/files/${fileId}/download`, {
+ responseType: 'blob',
+ });
+ return response.data;
+ },
+
+ downloadBatch: async (sessionId: string, fileIndices: number[]) => {
+ const response = await api.post(
+ '/files/download-batch',
+ { session_id: sessionId, file_indices: fileIndices },
+ { responseType: 'blob' }
+ );
+ return response.data;
+ },
+
+ cleanupSession: async (sessionId: string) => {
+ const response = await api.delete(`/files/session/${sessionId}`);
+ return response.data;
+ },
+};
+
+export const metadataService = {
+ updateMetadata: async (fileId: string, sessionId: string, fileIndex: number, metadata: any) => {
+ const response = await api.put(`/metadata/${fileId}`, {
+ session_id: sessionId,
+ file_index: fileIndex,
+ metadata,
+ });
+ return response.data;
+ },
+
+ batchUpdate: async (sessionId: string, fileIndices: number[], metadata: any) => {
+ const response = await api.post('/metadata/batch-update', {
+ session_id: sessionId,
+ file_indices: fileIndices,
+ metadata,
+ });
+ return response.data;
+ },
+};
+
+export const templateService = {
+ listTemplates: async () => {
+ const response = await api.get('/templates/');
+ return response.data;
+ },
+
+ createTemplate: async (template: any) => {
+ const response = await api.post('/templates/', template);
+ return response.data;
+ },
+
+ getTemplate: async (name: string) => {
+ const response = await api.get(`/templates/${name}`);
+ return response.data;
+ },
+
+ deleteTemplate: async (name: string) => {
+ const response = await api.delete(`/templates/${name}`);
+ return response.data;
+ },
+};
diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts
new file mode 100644
index 0000000..39204bb
--- /dev/null
+++ b/frontend/src/store/authStore.ts
@@ -0,0 +1,35 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { User } from '../types';
+
+interface AuthState {
+ user: User | null;
+ isAuthenticated: boolean;
+ login: (user: User, accessToken: string, refreshToken: string) => void;
+ logout: () => void;
+}
+
+export const useAuthStore = create()(
+ persist(
+ (set) => ({
+ user: null,
+ isAuthenticated: false,
+
+ login: (user, accessToken, refreshToken) => {
+ localStorage.setItem('access_token', accessToken);
+ localStorage.setItem('refresh_token', refreshToken);
+ set({ user, isAuthenticated: true });
+ },
+
+ logout: () => {
+ localStorage.removeItem('access_token');
+ localStorage.removeItem('refresh_token');
+ set({ user: null, isAuthenticated: false });
+ },
+ }),
+ {
+ name: 'auth-storage',
+ partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
+ }
+ )
+);
diff --git a/frontend/src/store/fileStore.ts b/frontend/src/store/fileStore.ts
new file mode 100644
index 0000000..8545b04
--- /dev/null
+++ b/frontend/src/store/fileStore.ts
@@ -0,0 +1,48 @@
+import { create } from 'zustand';
+import { FileData } from '../types';
+
+interface FileState {
+ sessionId: string | null;
+ files: FileData[];
+ selectedIndices: number[];
+
+ setSession: (sessionId: string, files: FileData[]) => void;
+ updateFile: (index: number, file: FileData) => void;
+ toggleSelection: (index: number) => void;
+ selectAll: () => void;
+ deselectAll: () => void;
+ clearSession: () => void;
+}
+
+export const useFileStore = create((set) => ({
+ sessionId: null,
+ files: [],
+ selectedIndices: [],
+
+ setSession: (sessionId, files) =>
+ set({ sessionId, files, selectedIndices: [] }),
+
+ updateFile: (index, file) =>
+ set((state) => {
+ const newFiles = [...state.files];
+ newFiles[index] = file;
+ return { files: newFiles };
+ }),
+
+ toggleSelection: (index) =>
+ set((state) => ({
+ selectedIndices: state.selectedIndices.includes(index)
+ ? state.selectedIndices.filter((i) => i !== index)
+ : [...state.selectedIndices, index],
+ })),
+
+ selectAll: () =>
+ set((state) => ({
+ selectedIndices: state.files.map((_, i) => i),
+ })),
+
+ deselectAll: () => set({ selectedIndices: [] }),
+
+ clearSession: () =>
+ set({ sessionId: null, files: [], selectedIndices: [] }),
+}));
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
new file mode 100644
index 0000000..d6819db
--- /dev/null
+++ b/frontend/src/types/index.ts
@@ -0,0 +1,36 @@
+export interface User {
+ id: number;
+ username: string;
+ email?: string;
+ full_name?: string;
+ auth_method: string;
+}
+
+export interface FileData {
+ file_id: string;
+ filename: string;
+ filepath: string;
+ file_type: string;
+ size: number;
+ uploaded_at: string;
+ current_metadata: Metadata;
+ suggested_metadata: Metadata;
+ metadata_source: string;
+}
+
+export interface Metadata {
+ title: string;
+ subject?: string;
+ keywords?: string;
+ author?: string;
+ copyright?: string;
+ comments?: string;
+ custom_fields?: Record;
+}
+
+export interface UploadResponse {
+ success: boolean;
+ session_id: string;
+ files: FileData[];
+ message?: string;
+}
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000..e522706
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,19 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ 'oliver-gold': {
+ 400: '#FFC407',
+ 500: '#e6b007',
+ 600: '#cc9d06',
+ },
+ },
+ },
+ },
+ plugins: [],
+}
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
new file mode 100644
index 0000000..3934b8f
--- /dev/null
+++ b/frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json
new file mode 100644
index 0000000..42872c5
--- /dev/null
+++ b/frontend/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..1184373
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ base: '/solventum-image-metadata/',
+ server: {
+ port: 3000,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, '')
+ }
+ }
+ },
+ build: {
+ outDir: 'dist',
+ assetsDir: 'assets',
+ sourcemap: false,
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ vendor: ['react', 'react-dom', 'react-router-dom'],
+ store: ['zustand'],
+ api: ['axios']
+ }
+ }
+ }
+ }
+})