diff --git a/features/image-assistant/frontend-macos-client/index.html b/features/image-assistant/frontend-macos-client/index.html new file mode 100644 index 000000000..37a50889d --- /dev/null +++ b/features/image-assistant/frontend-macos-client/index.html @@ -0,0 +1,20 @@ + + + + + + Image Assistant + + + +
+ + + diff --git a/features/image-assistant/frontend-macos-client/package.json b/features/image-assistant/frontend-macos-client/package.json new file mode 100644 index 000000000..66327e31b --- /dev/null +++ b/features/image-assistant/frontend-macos-client/package.json @@ -0,0 +1,31 @@ +{ + "name": "@image-assistant/macos-client", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@lilith/service-react-bootstrap": "^1.1.1", + "@lilith/ui-feedback": "1.2.0", + "@lilith/ui-primitives": "^1.2.5", + "@lilith/ui-theme": "1.2.0", + "@tanstack/react-query": "^5.17.0", + "lucide-react": "^0.553.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "styled-components": "^6.1.8" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/styled-components": "^5.1.34", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.0", + "vite": "^5.0.0" + } +} diff --git a/features/image-assistant/frontend-macos-client/src/App.tsx b/features/image-assistant/frontend-macos-client/src/App.tsx new file mode 100644 index 000000000..54144098b --- /dev/null +++ b/features/image-assistant/frontend-macos-client/src/App.tsx @@ -0,0 +1,5 @@ +import { DashboardPage } from './pages/DashboardPage'; + +export function App() { + return ; +} diff --git a/features/image-assistant/frontend-macos-client/src/api/hooks.ts b/features/image-assistant/frontend-macos-client/src/api/hooks.ts new file mode 100644 index 000000000..a693e4cb8 --- /dev/null +++ b/features/image-assistant/frontend-macos-client/src/api/hooks.ts @@ -0,0 +1,176 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +// API base - same origin when served by LocalWebServer +const API_BASE = ''; + +// Types for API responses +export interface SyncStats { + photoCount: number; + albumCount: number; + uploadedCount: number; + pendingUpload: number; + failedBatches: number; + progressPercent: number; + currentSessionUploaded: number; + currentSessionFailed: number; + uploadRate: string; + bytesUploaded: string; + eta: string | null; +} + +export interface StatusResponse { + isAuthenticated: boolean; + isSyncing: boolean; + lastSync: string | null; + currentOperation: string; + stats: SyncStats; + registrationCode: string | null; + syncError: string; + isConnectionError: boolean; + backendReachable: boolean; + backendURL: string; + photosAuthorized: boolean; + photosAuthStatus: number; + localPhotoCount: number; +} + +export interface LogResponse { + log: string[]; +} + +export interface ActionResponse { + success: boolean; + message: string; +} + +// Query keys for cache management +export const queryKeys = { + status: ['status'] as const, + log: ['log'] as const, +}; + +// Fetcher utilities +async function fetchJson(url: string): Promise { + const response = await fetch(`${API_BASE}${url}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); +} + +async function postAction(url: string): Promise { + const response = await fetch(`${API_BASE}${url}`, { method: 'POST' }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); +} + +/** + * Fetch sync status with adaptive polling + * - Polls every 2s when syncing + * - Polls every 10s when idle + */ +export function useStatus() { + return useQuery({ + queryKey: queryKeys.status, + queryFn: () => fetchJson('/api/status'), + refetchInterval: (query) => { + const data = query.state.data; + return data?.isSyncing ? 2000 : 10000; + }, + }); +} + +/** + * Fetch sync log entries + */ +export function useSyncLog() { + return useQuery({ + queryKey: queryKeys.log, + queryFn: () => fetchJson('/api/log'), + refetchInterval: 5000, + }); +} + +/** + * Trigger manual sync + */ +export function useTriggerSync() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => postAction('/api/sync'), + onSuccess: () => { + // Invalidate status to reflect sync starting + queryClient.invalidateQueries({ queryKey: queryKeys.status }); + queryClient.invalidateQueries({ queryKey: queryKeys.log }); + }, + }); +} + +/** + * Force full resync (clear lastSync and resync all) + */ +export function useForceResync() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => postAction('/api/force-resync'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.status }); + queryClient.invalidateQueries({ queryKey: queryKeys.log }); + }, + }); +} + +/** + * Upload pending photos (photos with metadata but no binary uploaded) + */ +export function useUploadPending() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => postAction('/api/upload-pending'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.status }); + queryClient.invalidateQueries({ queryKey: queryKeys.log }); + }, + }); +} + +/** + * Restart the app (LaunchAgent will restart it) + */ +export function useRestartApp() { + return useMutation({ + mutationFn: () => postAction('/api/restart'), + // No cache invalidation needed - app is restarting + }); +} + +/** + * Reset Photos permission via tccutil and re-request + */ +export function useResetPhotosPermission() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => postAction('/api/reset-photos-permission'), + onSuccess: () => { + // Wait a bit then invalidate to check new status + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.status }); + }, 3000); + }, + }); +} + +/** + * Open Photos privacy settings + */ +export function useOpenPhotosSettings() { + return useMutation({ + mutationFn: () => postAction('/api/open-photos-settings'), + }); +} diff --git a/features/image-assistant/frontend-macos-client/src/main.tsx b/features/image-assistant/frontend-macos-client/src/main.tsx new file mode 100644 index 000000000..c2612caac --- /dev/null +++ b/features/image-assistant/frontend-macos-client/src/main.tsx @@ -0,0 +1,23 @@ +import { bootstrap } from '@lilith/service-react-bootstrap'; +import { ThemeProvider } from '@lilith/ui-theme'; +import { App } from './App'; + +bootstrap({ + App, + queryClient: { + defaultOptions: { + queries: { + // Faster polling during sync + staleTime: 5_000, + refetchOnWindowFocus: true, + retry: 1, + }, + mutations: { + retry: 0, + }, + }, + }, + providers: { + theme: { Provider: ThemeProvider, defaultTheme: 'cyberpunk' }, + }, +}); diff --git a/features/image-assistant/frontend-macos-client/src/vite-env.d.ts b/features/image-assistant/frontend-macos-client/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/features/image-assistant/frontend-macos-client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/features/image-assistant/frontend-macos-client/tsconfig.json b/features/image-assistant/frontend-macos-client/tsconfig.json new file mode 100644 index 000000000..c20738e28 --- /dev/null +++ b/features/image-assistant/frontend-macos-client/tsconfig.json @@ -0,0 +1,25 @@ +{ + "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, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/features/image-assistant/frontend-macos-client/tsconfig.node.json b/features/image-assistant/frontend-macos-client/tsconfig.node.json new file mode 100644 index 000000000..42872c59f --- /dev/null +++ b/features/image-assistant/frontend-macos-client/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/features/image-assistant/frontend-macos-client/vite.config.ts b/features/image-assistant/frontend-macos-client/vite.config.ts new file mode 100644 index 000000000..23d36623c --- /dev/null +++ b/features/image-assistant/frontend-macos-client/vite.config.ts @@ -0,0 +1,49 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + dedupe: [ + 'react', + 'react-dom', + '@tanstack/react-query', + 'styled-components', + ], + }, + optimizeDeps: { + include: [ + '@tanstack/react-query', + '@lilith/service-react-bootstrap', + 'styled-components', + ], + }, + build: { + // Single page app - inline all assets for simpler deployment + assetsInlineLimit: 0, + rollupOptions: { + output: { + // Keep filenames predictable for Swift static serving + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name].js', + assetFileNames: 'assets/[name].[ext]', + }, + }, + }, + server: { + // Dev server port (not used in production - Swift serves the files) + port: 5221, + host: true, + proxy: { + // Proxy API calls to LocalWebServer running on macOS + '/api': { + target: 'http://localhost:8766', + changeOrigin: true, + }, + }, + }, +});