feat(image-assistant): Enhance macOS-specific frontend functionality with new UI components, interactions, and platform optimizations

This commit is contained in:
Lilith 2026-01-18 09:46:15 -08:00
parent fc22bd51c9
commit 30e2823bec
9 changed files with 340 additions and 0 deletions

View file

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Image Assistant</title>
<style>
/* Prevent flash of unstyled content */
body {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
margin: 0;
min-height: 100vh;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -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"
}
}

View file

@ -0,0 +1,5 @@
import { DashboardPage } from './pages/DashboardPage';
export function App() {
return <DashboardPage />;
}

View file

@ -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<T>(url: string): Promise<T> {
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<ActionResponse> {
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<StatusResponse>('/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<LogResponse>('/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'),
});
}

View file

@ -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' },
},
});

View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -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" }]
}

View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View file

@ -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,
},
},
},
});