├── dist ├── _redirects ├── registerSW.js ├── manifest.webmanifest ├── sw.js ├── manifest.json ├── index.html └── workbox-5ffe50d4.js ├── public ├── _redirects ├── qpv3.jpg ├── manifest.json └── sw.js ├── src ├── vite-env.d.ts ├── utils │ ├── fileUtils.ts │ └── currency.ts ├── main.tsx ├── components │ ├── ColorPicker.tsx │ ├── LiquidGlass.tsx │ ├── Header.tsx │ ├── ui │ │ └── Toaster.tsx │ └── Sidebar.tsx ├── store │ ├── settingsStore.ts │ ├── flightLogStore.ts │ ├── themeStore.ts │ ├── storageLocationStore.ts │ ├── todoStore.ts │ ├── buildStore.ts │ ├── galleryStore.ts │ ├── linkStore.ts │ └── inventoryStore.ts ├── models │ └── types.ts ├── App.tsx └── pages │ ├── Dashboard.tsx │ ├── Gallery.tsx │ ├── BuildNotes.tsx │ ├── LiquidGlassDemo.tsx │ └── FlightLog.tsx ├── SECURITY.md ├── postcss.config.js ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── vite.config.ts ├── index.html ├── package.json ├── android.md ├── readmeAlt.md ├── tailwind.config.js ├── windows.md ├── linux.md ├── LICENSE └── README.md /dist/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Please report any app vulnerabilities to the issues page. Thank You 2 | -------------------------------------------------------------------------------- /public/qpv3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hasmeni/QuadParts/HEAD/public/qpv3.jpg -------------------------------------------------------------------------------- /src/utils/fileUtils.ts: -------------------------------------------------------------------------------- 1 | // This file is intentionally left empty as we're removing Tauri functionality 2 | export {}; -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /dist/registerSW.js: -------------------------------------------------------------------------------- 1 | if('serviceWorker' in navigator) {window.addEventListener('load', () => {navigator.serviceWorker.register('/sw.js', { scope: '/' })})} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | import { ToasterProvider } from './components/ui/Toaster'; 6 | 7 | createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | 11 | 12 | 13 | ); -------------------------------------------------------------------------------- /dist/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"Drone Parts Inventory","short_name":"DroneInv","description":"Track and manage your drone parts inventory","start_url":"/","display":"standalone","background_color":"#131419","theme_color":"#131419","lang":"en","scope":"/","icons":[{"src":"/icons/icon-192x192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/icons/icon-512x512.png","sizes":"512x512","type":"image/png"}]} 2 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | } 28 | ); 29 | -------------------------------------------------------------------------------- /dist/sw.js: -------------------------------------------------------------------------------- 1 | if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise((s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()})).then((()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e})));self.define=(n,t)=>{const r=e||("document"in self?document.currentScript.src:"")||location.href;if(s[r])return;let o={};const c=e=>i(e,r),l={module:{uri:r},exports:o,require:c};s[r]=Promise.all(n.map((e=>l[e]||c(e)))).then((e=>(t(...e),o)))}}define(["./workbox-5ffe50d4"],(function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"assets/index-BL-1wdXx.js",revision:null},{url:"assets/index-BNbhJLht.css",revision:null},{url:"index.html",revision:"3ebe0455a92f0dcc5efda225a7a9e4bb"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"manifest.webmanifest",revision:"c5434722c89942ae34e302eec1edc6c2"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html")))})); 2 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { VitePWA } from 'vite-plugin-pwa'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | react(), 8 | VitePWA({ 9 | registerType: 'autoUpdate', 10 | includeAssets: ['icons/*.png'], 11 | manifest: { 12 | name: 'Drone Parts Inventory', 13 | short_name: 'DroneInv', 14 | description: 'Track and manage your drone parts inventory', 15 | theme_color: '#131419', 16 | background_color: '#131419', 17 | display: 'standalone', 18 | icons: [ 19 | { 20 | src: '/icons/icon-192x192.png', 21 | sizes: '192x192', 22 | type: 'image/png', 23 | purpose: 'any maskable' 24 | }, 25 | { 26 | src: '/icons/icon-512x512.png', 27 | sizes: '512x512', 28 | type: 'image/png' 29 | } 30 | ] 31 | } 32 | }) 33 | ], 34 | optimizeDeps: { 35 | exclude: ['lucide-react'], 36 | }, 37 | }); -------------------------------------------------------------------------------- /src/utils/currency.ts: -------------------------------------------------------------------------------- 1 | import { useSettingsStore } from '../store/settingsStore'; 2 | 3 | // Get currency symbol based on currency code 4 | export const getCurrencySymbol = (currencyCode: string): string => { 5 | const symbols: { [key: string]: string } = { 6 | 'USD': '$', 7 | 'EUR': '€', 8 | 'GBP': '£', 9 | 'CAD': 'C$', 10 | 'AUD': 'A$', 11 | }; 12 | return symbols[currencyCode] || '$'; 13 | }; 14 | 15 | // Format currency using the settings store 16 | export const formatCurrency = (value: number, currencyCode?: string): string => { 17 | const { settings } = useSettingsStore.getState(); 18 | const currency = currencyCode || settings.currencyFormat; 19 | 20 | return new Intl.NumberFormat('en-US', { 21 | style: 'currency', 22 | currency: currency 23 | }).format(value); 24 | }; 25 | 26 | // Get currency display text (e.g., "USD ($)") 27 | export const getCurrencyDisplay = (currencyCode?: string): string => { 28 | const { settings } = useSettingsStore.getState(); 29 | const currency = currencyCode || settings.currencyFormat; 30 | const symbol = getCurrencySymbol(currency); 31 | 32 | return `${currency} (${symbol})`; 33 | }; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Drone Parts Inventory 12 | 13 | 14 |
15 | 16 | 29 | 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drone-parts-inventory", 3 | "private": true, 4 | "version": "3.2", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --host 0.0.0.0", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint .", 11 | "generate-pwa-assets": "pwa-assets-generator" 12 | }, 13 | "dependencies": { 14 | "lucide-react": "^0.358.0", 15 | "react": "^18.3.1", 16 | "react-dom": "^18.3.1", 17 | "react-router-dom": "^6.22.3", 18 | "uuid": "^9.0.1", 19 | "zustand": "^4.5.2" 20 | }, 21 | "devDependencies": { 22 | "@eslint/js": "^9.9.1", 23 | "@types/react": "^18.3.5", 24 | "@types/react-dom": "^18.3.0", 25 | "@types/uuid": "^9.0.8", 26 | "@vite-pwa/assets-generator": "^1.0.0", 27 | "@vitejs/plugin-react": "^4.4.0", 28 | "autoprefixer": "^10.4.18", 29 | "eslint": "^8.56.0", 30 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 31 | "eslint-plugin-react-refresh": "^0.4.5", 32 | "globals": "^15.9.0", 33 | "postcss": "^8.4.35", 34 | "tailwindcss": "^3.4.1", 35 | "typescript": "^5.3.3", 36 | "typescript-eslint": "^7.2.0", 37 | "vite": "^6.3.5", 38 | "vite-plugin-pwa": "^1.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Drone Parts Inventory", 3 | "short_name": "DroneInv", 4 | "description": "Track and manage your drone parts inventory", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#131419", 8 | "theme_color": "#131419", 9 | "icons": [ 10 | { 11 | "src": "/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/icons/icon-96x96.png", 17 | "sizes": "96x96", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "/icons/icon-128x128.png", 22 | "sizes": "128x128", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "/icons/icon-144x144.png", 27 | "sizes": "144x144", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "/icons/icon-152x152.png", 32 | "sizes": "152x152", 33 | "type": "image/png" 34 | }, 35 | { 36 | "src": "/icons/icon-192x192.png", 37 | "sizes": "192x192", 38 | "type": "image/png", 39 | "purpose": "any maskable" 40 | }, 41 | { 42 | "src": "/icons/icon-384x384.png", 43 | "sizes": "384x384", 44 | "type": "image/png" 45 | }, 46 | { 47 | "src": "/icons/icon-512x512.png", 48 | "sizes": "512x512", 49 | "type": "image/png" 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Drone Parts Inventory", 3 | "short_name": "DroneInv", 4 | "description": "Track and manage your drone parts inventory", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#131419", 8 | "theme_color": "#131419", 9 | "icons": [ 10 | { 11 | "src": "/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/icons/icon-96x96.png", 17 | "sizes": "96x96", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "/icons/icon-128x128.png", 22 | "sizes": "128x128", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "/icons/icon-144x144.png", 27 | "sizes": "144x144", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "/icons/icon-152x152.png", 32 | "sizes": "152x152", 33 | "type": "image/png" 34 | }, 35 | { 36 | "src": "/icons/icon-192x192.png", 37 | "sizes": "192x192", 38 | "type": "image/png", 39 | "purpose": "any maskable" 40 | }, 41 | { 42 | "src": "/icons/icon-384x384.png", 43 | "sizes": "384x384", 44 | "type": "image/png" 45 | }, 46 | { 47 | "src": "/icons/icon-512x512.png", 48 | "sizes": "512x512", 49 | "type": "image/png" 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Drone Parts Inventory 12 | 13 | 14 | 15 | 16 |
17 | 30 | 31 | -------------------------------------------------------------------------------- /src/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ColorPickerProps { 4 | label: string; 5 | value: string; 6 | onChange: (color: string) => void; 7 | className?: string; 8 | } 9 | 10 | const ColorPicker: React.FC = ({ label, value, onChange, className = '' }) => { 11 | return ( 12 |
13 | 16 |
17 | onChange(e.target.value)} 21 | className="w-10 h-10 rounded-lg border border-white/20 cursor-pointer bg-transparent" 22 | style={{ 23 | backgroundColor: value, 24 | borderColor: value === '#ffffff' ? '#404040' : 'rgba(255, 255, 255, 0.2)' 25 | }} 26 | /> 27 | onChange(e.target.value)} 31 | className="w-20 px-2 py-1 liquid-glass border border-white/20 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-500/50 backdrop-blur-sm" 32 | placeholder="#000000" 33 | /> 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default ColorPicker; -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | const CACHE_NAME = 'drone-inventory-v1'; 2 | const urlsToCache = [ 3 | '/', 4 | '/index.html', 5 | '/manifest.json', 6 | '/icons/icon-192x192.png', 7 | '/icons/icon-512x512.png' 8 | ]; 9 | 10 | self.addEventListener('install', (event) => { 11 | event.waitUntil( 12 | caches.open(CACHE_NAME) 13 | .then((cache) => cache.addAll(urlsToCache)) 14 | ); 15 | }); 16 | 17 | self.addEventListener('fetch', (event) => { 18 | event.respondWith( 19 | caches.match(event.request) 20 | .then((response) => { 21 | if (response) { 22 | return response; 23 | } 24 | return fetch(event.request) 25 | .then((response) => { 26 | if (!response || response.status !== 200 || response.type !== 'basic') { 27 | return response; 28 | } 29 | const responseToCache = response.clone(); 30 | caches.open(CACHE_NAME) 31 | .then((cache) => { 32 | cache.put(event.request, responseToCache); 33 | }); 34 | return response; 35 | }); 36 | }) 37 | ); 38 | }); 39 | 40 | self.addEventListener('activate', (event) => { 41 | event.waitUntil( 42 | caches.keys().then((cacheNames) => { 43 | return Promise.all( 44 | cacheNames.map((cacheName) => { 45 | if (cacheName !== CACHE_NAME) { 46 | return caches.delete(cacheName); 47 | } 48 | }) 49 | ); 50 | }) 51 | ); 52 | }); -------------------------------------------------------------------------------- /src/components/LiquidGlass.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react'; 2 | 3 | interface LiquidGlassProps { 4 | children: ReactNode; 5 | className?: string; 6 | variant?: 'card' | 'button' | 'input' | 'modal' | 'sidebar' | 'header' | 'default'; 7 | onClick?: () => void; 8 | disabled?: boolean; 9 | } 10 | 11 | const LiquidGlass: React.FC = ({ 12 | children, 13 | className = '', 14 | variant = 'default', 15 | onClick, 16 | disabled = false, 17 | }) => { 18 | const baseClasses = 'liquid-glass'; 19 | const variantClasses = { 20 | card: 'liquid-card', 21 | button: 'liquid-button', 22 | input: 'liquid-input', 23 | modal: 'liquid-modal', 24 | sidebar: 'liquid-sidebar', 25 | header: 'liquid-header', 26 | default: '', 27 | }; 28 | 29 | const classes = `${baseClasses} ${variantClasses[variant]} ${className}`.trim(); 30 | 31 | if (variant === 'button') { 32 | return ( 33 | 43 | ); 44 | } 45 | 46 | return ( 47 |
48 |
49 | {children} 50 |
51 |
52 | ); 53 | }; 54 | 55 | export default LiquidGlass; -------------------------------------------------------------------------------- /src/store/settingsStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | const STORAGE_KEY = 'quadparts_settings'; 4 | 5 | interface Settings { 6 | lowStockThreshold: number; 7 | defaultCategory: string; 8 | currencyFormat: string; 9 | backupLocation: string; 10 | enableAutoBackup: boolean; 11 | autoBackupFrequency: string; 12 | } 13 | 14 | interface SettingsState { 15 | settings: Settings; 16 | updateSettings: (newSettings: Partial) => void; 17 | } 18 | 19 | // Default settings 20 | const defaultSettings: Settings = { 21 | lowStockThreshold: 3, 22 | defaultCategory: 'Uncategorized', 23 | currencyFormat: 'USD', 24 | backupLocation: 'Downloads', 25 | enableAutoBackup: true, 26 | autoBackupFrequency: '7', 27 | }; 28 | 29 | // Load saved settings from localStorage 30 | const loadSavedSettings = (): Settings => { 31 | try { 32 | const savedSettings = localStorage.getItem(STORAGE_KEY); 33 | if (savedSettings) { 34 | return JSON.parse(savedSettings); 35 | } 36 | } catch (error) { 37 | console.error('Error loading settings from localStorage:', error); 38 | } 39 | return defaultSettings; 40 | }; 41 | 42 | export const useSettingsStore = create((set) => ({ 43 | settings: loadSavedSettings(), 44 | 45 | updateSettings: (newSettings) => { 46 | set((state) => { 47 | const updatedSettings = { ...state.settings, ...newSettings }; 48 | // Save to localStorage 49 | localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedSettings)); 50 | return { settings: updatedSettings }; 51 | }); 52 | }, 53 | })); -------------------------------------------------------------------------------- /src/store/flightLogStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | 4 | export interface FlightLogEntry { 5 | id: string; 6 | date: string; 7 | drone: string; 8 | location: string; 9 | duration: string; 10 | notes: string; 11 | issues: string; 12 | } 13 | 14 | interface FlightLogState { 15 | flightLogs: FlightLogEntry[]; 16 | addFlightLog: (log: Omit) => void; 17 | updateFlightLog: (id: string, log: Partial) => void; 18 | deleteFlightLog: (id: string) => void; 19 | clearAllFlightLogs: () => void; 20 | setFlightLogs: (logs: FlightLogEntry[]) => void; 21 | } 22 | 23 | export const useFlightLogStore = create()( 24 | persist( 25 | (set, get) => ({ 26 | flightLogs: [], 27 | 28 | addFlightLog: (log) => { 29 | const newLog: FlightLogEntry = { 30 | ...log, 31 | id: Date.now().toString() 32 | }; 33 | set((state) => ({ 34 | flightLogs: [...state.flightLogs, newLog] 35 | })); 36 | }, 37 | 38 | updateFlightLog: (id, updatedLog) => { 39 | set((state) => ({ 40 | flightLogs: state.flightLogs.map((log) => 41 | log.id === id ? { ...log, ...updatedLog } : log 42 | ) 43 | })); 44 | }, 45 | 46 | deleteFlightLog: (id) => { 47 | set((state) => ({ 48 | flightLogs: state.flightLogs.filter((log) => log.id !== id) 49 | })); 50 | }, 51 | 52 | clearAllFlightLogs: () => { 53 | set({ flightLogs: [] }); 54 | }, 55 | 56 | setFlightLogs: (logs) => { 57 | set({ flightLogs: logs }); 58 | } 59 | }), 60 | { 61 | name: 'quadparts_flight_logs_data', 62 | version: 1, 63 | migrate: (persistedState: any, version: number) => { 64 | if (version === 0) { 65 | // Handle migration from old format if needed 66 | return persistedState; 67 | } 68 | return persistedState; 69 | } 70 | } 71 | ) 72 | ); -------------------------------------------------------------------------------- /android.md: -------------------------------------------------------------------------------- 1 | # How to Turn QuadParts (Vite/React) Into an Android APK 2 | 3 | This guide explains how to package your Vite/React app as an Android APK. The most common way is to use **Capacitor** (by the creators of Ionic), which wraps your web app in a native Android WebView. Alternatives like Cordova are also mentioned. 4 | 5 | --- 6 | 7 | ## Method 1: Using Capacitor (Recommended) 8 | 9 | [Capacitor](https://capacitorjs.com/) lets you run web apps as native mobile apps. 10 | 11 | ### Steps: 12 | 13 | 1. **Build your Vite app:** 14 | ```bash 15 | npm run build 16 | ``` 17 | This creates a `dist` folder with your static files. 18 | 19 | 2. **Install Capacitor:** 20 | ```bash 21 | npm install --save @capacitor/core @capacitor/cli 22 | npx cap init 23 | ``` 24 | - App name: `QuadParts` 25 | - App ID: e.g. `com.example.quadparts` 26 | 27 | 3. **Add Android platform:** 28 | ```bash 29 | npm install @capacitor/android 30 | npx cap add android 31 | ``` 32 | 33 | 4. **Copy your build to the native project:** 34 | ```bash 35 | npx cap copy 36 | ``` 37 | 38 | 5. **Open the Android project in Android Studio:** 39 | ```bash 40 | npx cap open android 41 | ``` 42 | - You can now run, build, and generate an APK using Android Studio's tools. 43 | 44 | 6. **Build the APK in Android Studio:** 45 | - Click "Build > Build Bundle(s) / APK(s) > Build APK(s)". 46 | - The APK will be in the `app/build/outputs/apk/` directory. 47 | 48 | --- 49 | 50 | ## Method 2: Using Cordova (Alternative) 51 | 52 | [Cordova](https://cordova.apache.org/) is another tool for wrapping web apps as mobile apps. 53 | 54 | ### Steps: 55 | 1. **Install Cordova:** 56 | ```bash 57 | npm install -g cordova 58 | ``` 59 | 2. **Create a Cordova project and copy your `dist` files into the `www` folder.** 60 | 3. **Add the Android platform:** 61 | ```bash 62 | cordova platform add android 63 | ``` 64 | 4. **Build the APK:** 65 | ```bash 66 | cordova build android 67 | ``` 68 | 69 | --- 70 | 71 | ## Method 3: Use a WebView Wrapper App 72 | 73 | There are tools and templates (like [WebViewGold](https://www.webviewgold.com/)) that let you create an Android app from a URL or local files with minimal coding. 74 | - These are commercial or template-based solutions. 75 | 76 | --- 77 | 78 | ## Notes 79 | - For best results, test your app thoroughly on mobile devices. 80 | - You can access native device features using Capacitor or Cordova plugins. 81 | - For advanced configuration, see the [Capacitor docs](https://capacitorjs.com/docs) or [Cordova docs](https://cordova.apache.org/docs/). 82 | 83 | --- 84 | -------------------------------------------------------------------------------- /readmeAlt.md: -------------------------------------------------------------------------------- 1 | # Alternate Deployment Methods for QuadParts v3.2 2 | 3 | This guide provides alternate ways to deploy your QuadParts (Vite + React) application beyond traditional server hosting. Choose the method that best fits your needs. 4 | 5 | --- 6 | 7 | ## 1. Deploy to Vercel 8 | 9 | [Vercel](https://vercel.com/) offers seamless deployment for Vite/React projects. 10 | 11 | ### Steps: 12 | 1. **Push your code to GitHub, GitLab, or Bitbucket.** 13 | 2. **Sign up or log in to [Vercel](https://vercel.com/).** 14 | 3. **Import your repository:** 15 | - Click "New Project" and select your repo. 16 | - Vercel auto-detects Vite. Use default settings or set `Build Command` to `vite build` and `Output Directory` to `dist`. 17 | 4. **Deploy!** 18 | - Vercel will build and host your site. You get a live URL instantly. 19 | 20 | --- 21 | 22 | ## 2. Deploy to Netlify 23 | 24 | [Netlify](https://netlify.com/) is another popular static site host. 25 | 26 | ### Steps: 27 | 1. **Push your code to GitHub, GitLab, or Bitbucket.** 28 | 2. **Sign up or log in to [Netlify](https://netlify.com/).** 29 | 3. **New site from Git:** 30 | - Connect your repo. 31 | - Set `Build Command` to `vite build` and `Publish directory` to `dist`. 32 | 4. **Deploy site.** 33 | - Netlify will build and host your app with a public URL. 34 | 35 | --- 36 | 37 | ## 3. Deploy to GitHub Pages 38 | 39 | You can deploy the static build to GitHub Pages using the [vite-plugin-gh-pages](https://www.npmjs.com/package/vite-plugin-gh-pages) or manually. 40 | 41 | ### Steps (Manual): 42 | 1. **Build your app:** 43 | ```bash 44 | npm run build 45 | ``` 46 | 2. **Copy the contents of the `dist` folder to a `gh-pages` branch.** 47 | 3. **Push the `gh-pages` branch to GitHub.** 48 | 4. **In your repo settings, set GitHub Pages to use the `gh-pages` branch.** 49 | 50 | Or use a plugin for automation. 51 | 52 | --- 53 | 54 | ## 4. Deploy to Any Static Host (e.g., S3, Firebase Hosting, Surge) 55 | 56 | ### Steps: 57 | 1. **Build your app:** 58 | ```bash 59 | npm run build 60 | ``` 61 | 2. **Upload the contents of the `dist` folder to your static host.** 62 | - For AWS S3: Use the AWS Console or CLI. 63 | - For Firebase Hosting: Use `firebase deploy` after initializing. 64 | - For Surge: Run `npx surge dist/`. 65 | 66 | --- 67 | 68 | ## Notes 69 | - Make sure to set the correct base path if deploying to a subdirectory (see Vite docs: [Base Public Path](https://vitejs.dev/guide/build.html#public-base-path)). 70 | - For SPA routing, enable redirect rules (e.g., `_redirects` file for Netlify, or custom rules for S3). 71 | 72 | --- 73 | 74 | For more details, see the official Vite and React deployment docs. -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: { 6 | colors: { 7 | primary: { 8 | 50: '#eef2ff', 9 | 100: '#dce4ff', 10 | 200: '#c0ccff', 11 | 300: '#a1a9ff', 12 | 400: '#8685fa', 13 | 500: '#7a62ef', 14 | 600: '#6e42e0', 15 | 700: '#5e32c5', 16 | 800: '#4c2c9f', 17 | 900: '#412a7e', 18 | 950: '#261655' 19 | }, 20 | secondary: { 21 | 50: '#f0fdf5', 22 | 100: '#dcfce9', 23 | 200: '#bbf7d7', 24 | 300: '#86efb9', 25 | 400: '#4ade95', 26 | 500: '#21c373', 27 | 600: '#16a35c', 28 | 700: '#15834c', 29 | 800: '#166841', 30 | 900: '#135538', 31 | 950: '#073022' 32 | }, 33 | accent: { 34 | 50: '#fff8ed', 35 | 100: '#ffefd3', 36 | 200: '#ffdca6', 37 | 300: '#fec46b', 38 | 400: '#fda834', 39 | 500: '#fc8c0f', 40 | 600: '#ed6b06', 41 | 700: '#c44e08', 42 | 800: '#9c3d0f', 43 | 900: '#7e340f', 44 | 950: '#431806' 45 | }, 46 | neutral: { 47 | 50: '#f7f7f8', 48 | 100: '#eeeef0', 49 | 200: '#d9dadf', 50 | 300: '#b9bcc6', 51 | 400: '#9295a4', 52 | 500: '#757887', 53 | 600: '#60626f', 54 | 700: '#4e505a', 55 | 800: '#343540', 56 | 900: '#24252d', 57 | 950: '#131419' 58 | } 59 | }, 60 | animation: { 61 | 'fade-in': 'fadeIn 0.3s ease-in-out', 62 | 'slide-up': 'slideUp 0.3s ease-in-out', 63 | 'slide-down': 'slideDown 0.3s ease-in-out', 64 | 'slide-right': 'slideRight 0.3s ease-in-out', 65 | 'slide-left': 'slideLeft 0.3s ease-in-out' 66 | }, 67 | keyframes: { 68 | fadeIn: { 69 | '0%': { opacity: '0' }, 70 | '100%': { opacity: '1' } 71 | }, 72 | slideUp: { 73 | '0%': { transform: 'translateY(10px)', opacity: '0' }, 74 | '100%': { transform: 'translateY(0)', opacity: '1' } 75 | }, 76 | slideDown: { 77 | '0%': { transform: 'translateY(-10px)', opacity: '0' }, 78 | '100%': { transform: 'translateY(0)', opacity: '1' } 79 | }, 80 | slideRight: { 81 | '0%': { transform: 'translateX(-10px)', opacity: '0' }, 82 | '100%': { transform: 'translateX(0)', opacity: '1' } 83 | }, 84 | slideLeft: { 85 | '0%': { transform: 'translateX(10px)', opacity: '0' }, 86 | '100%': { transform: 'translateX(0)', opacity: '1' } 87 | } 88 | } 89 | }, 90 | }, 91 | plugins: [], 92 | }; -------------------------------------------------------------------------------- /windows.md: -------------------------------------------------------------------------------- 1 | # How to Turn QuadParts (Vite/React) Into a Windows Executable (.exe) 2 | 3 | This guide explains how to package your Vite/React app as a Windows desktop executable. The most common and robust way is to use **Electron**. Alternative methods are also listed. 4 | 5 | --- 6 | 7 | ## Method 1: Using Electron (Recommended) 8 | 9 | Electron lets you run web apps as native desktop applications. 10 | 11 | ### Steps: 12 | 13 | 1. **Build your Vite app:** 14 | ```bash 15 | npm run build 16 | ``` 17 | This creates a `dist` folder with your static files. 18 | 19 | 2. **Install Electron and required tools:** 20 | ```bash 21 | npm install --save-dev electron electron-builder 22 | ``` 23 | 24 | 3. **Create an Electron main process file:** 25 | Create a file named `main.js` in your project root: 26 | ```js 27 | const { app, BrowserWindow } = require('electron'); 28 | const path = require('path'); 29 | 30 | function createWindow() { 31 | const win = new BrowserWindow({ 32 | width: 1200, 33 | height: 800, 34 | webPreferences: { 35 | nodeIntegration: false, 36 | contextIsolation: true, 37 | }, 38 | }); 39 | win.loadFile(path.join(__dirname, 'dist/index.html')); 40 | } 41 | 42 | app.whenReady().then(createWindow); 43 | app.on('window-all-closed', () => { 44 | if (process.platform !== 'darwin') app.quit(); 45 | }); 46 | ``` 47 | 48 | 4. **Add Electron start/build scripts to `package.json`:** 49 | ```json 50 | "main": "main.js", 51 | "scripts": { 52 | "electron": "electron .", 53 | "electron:build": "electron-builder --win --x64" 54 | } 55 | ``` 56 | 57 | 5. **Run your app in Electron:** 58 | ```bash 59 | npm run electron 60 | ``` 61 | 62 | 6. **Build the Windows .exe installer:** 63 | ```bash 64 | npm run electron:build 65 | ``` 66 | The output `.exe` will be in the `dist_electron` or `dist` folder (check your config). 67 | 68 | --- 69 | 70 | ## Method 2: Using Webview (Lightweight Alternative) 71 | 72 | [Webview](https://webview.dev/) is a lightweight alternative to Electron. 73 | - See [webview/webview-examples](https://github.com/webview/webview-examples) for templates. 74 | - You will need to write a small Go or Rust wrapper to load your `dist/index.html`. 75 | 76 | --- 77 | 78 | ## Method 3: Package Node.js Server with `pkg` (Not for pure static apps) 79 | 80 | If your app needs a Node.js backend, you can use [`pkg`](https://github.com/vercel/pkg) to create an .exe from a Node.js script. 81 | - This is not recommended for pure static Vite/React apps. 82 | 83 | --- 84 | 85 | ## Notes 86 | - Electron is the most popular and robust way to turn a web app into a Windows executable. 87 | - You can customize the Electron window, add icons, splash screens, and more. 88 | - For advanced configuration, see the [Electron Builder docs](https://www.electron.build/). 89 | 90 | --- 91 | 92 | -------------------------------------------------------------------------------- /linux.md: -------------------------------------------------------------------------------- 1 | # How to Package QuadParts (Vite/React) as a Linux .deb and AppImage 2 | 3 | This guide explains how to turn your Vite/React app into a Linux desktop application, packaged as a `.deb` (Debian/Ubuntu) and an AppImage (portable Linux app). The recommended approach is to use **Electron** and **electron-builder**. 4 | 5 | --- 6 | 7 | ## Prerequisites 8 | - Node.js and npm installed 9 | - Your Vite/React app builds successfully (`npm run build`) 10 | - Linux system or WSL/VM for building 11 | 12 | --- 13 | 14 | ## 1. Prepare Your App for Electron 15 | 16 | 1. **Build your Vite app:** 17 | ```bash 18 | npm run build 19 | ``` 20 | This creates a `dist` folder with your static files. 21 | 22 | 2. **Install Electron and electron-builder:** 23 | ```bash 24 | npm install --save-dev electron electron-builder 25 | ``` 26 | 27 | 3. **Create an Electron main process file (`main.js`):** 28 | ```js 29 | const { app, BrowserWindow } = require('electron'); 30 | const path = require('path'); 31 | 32 | function createWindow() { 33 | const win = new BrowserWindow({ 34 | width: 1200, 35 | height: 800, 36 | webPreferences: { 37 | nodeIntegration: false, 38 | contextIsolation: true, 39 | }, 40 | }); 41 | win.loadFile(path.join(__dirname, 'dist/index.html')); 42 | } 43 | 44 | app.whenReady().then(createWindow); 45 | app.on('window-all-closed', () => { 46 | if (process.platform !== 'darwin') app.quit(); 47 | }); 48 | ``` 49 | 50 | 4. **Update your `package.json`:** 51 | Add or update these fields: 52 | ```json 53 | "main": "main.js", 54 | "build": { 55 | "appId": "com.example.quadparts", 56 | "productName": "QuadParts", 57 | "files": [ 58 | "dist/**/*", 59 | "main.js" 60 | ], 61 | "linux": { 62 | "target": ["deb", "AppImage"], 63 | "category": "Utility" 64 | } 65 | }, 66 | "scripts": { 67 | "electron": "electron .", 68 | "electron:build": "electron-builder --linux" 69 | } 70 | ``` 71 | 72 | --- 73 | 74 | ## 2. Build .deb and AppImage Packages 75 | 76 | Run: 77 | ```bash 78 | npm run electron:build 79 | ``` 80 | - This will generate `.deb` and `.AppImage` files in the `dist/` or `dist_electron/` directory. 81 | 82 | --- 83 | 84 | ## 3. Test and Distribute 85 | - Test the generated files on a Linux system. 86 | - `.deb` can be installed on Debian/Ubuntu with: 87 | ```bash 88 | sudo dpkg -i QuadParts*.deb 89 | ``` 90 | - `.AppImage` is portable; make it executable and run: 91 | ```bash 92 | chmod +x QuadParts*.AppImage 93 | ./QuadParts*.AppImage 94 | ``` 95 | 96 | --- 97 | 98 | ## Notes 99 | - You can customize icons, metadata, and more in the `build` section of `package.json`. 100 | - For advanced options, see the [electron-builder Linux docs](https://www.electron.build/configuration/linux). 101 | - You can also build on Windows/macOS, but cross-compiling for Linux may require extra setup (see electron-builder docs). 102 | 103 | --- -------------------------------------------------------------------------------- /src/models/types.ts: -------------------------------------------------------------------------------- 1 | // Types for the inventory system 2 | 3 | export interface Part { 4 | id: string; 5 | name: string; 6 | category: string; 7 | subcategory?: string; 8 | quantity: number; 9 | price: number; 10 | location: string; 11 | description: string; 12 | imageUrls: string[]; 13 | manufacturer?: string; 14 | modelNumber?: string; 15 | dateAdded: string; 16 | lastModified?: string; 17 | notes?: string; 18 | inUse: number; 19 | status: 'in-stock' | 'in-use'; 20 | condition: 'new' | 'good' | 'fair' | 'poor' | 'broken' | 'needs-repair'; 21 | } 22 | 23 | export interface Category { 24 | id: string; 25 | name: string; 26 | description?: string; 27 | color: string; 28 | icon?: string; 29 | subcategories: Subcategory[]; 30 | dateAdded: string; 31 | lastModified?: string; 32 | } 33 | 34 | export interface Subcategory { 35 | id: string; 36 | name: string; 37 | description?: string; 38 | parentId: string; 39 | } 40 | 41 | export interface StorageLocation { 42 | id: string; 43 | name: string; 44 | description?: string; 45 | type: 'shelf' | 'drawer' | 'box' | 'cabinet' | 'room' | 'other'; 46 | capacity?: number; 47 | currentItems?: number; 48 | dateAdded: string; 49 | lastModified?: string; 50 | } 51 | 52 | export interface TodoItem { 53 | id: string; 54 | title: string; 55 | description?: string; 56 | completed: boolean; 57 | priority: 'low' | 'medium' | 'high'; 58 | dateCreated: string; 59 | dateDue?: string; 60 | dateCompleted?: string; 61 | relatedPartIds?: string[]; 62 | } 63 | 64 | export interface FilterOptions { 65 | categories: string[]; 66 | searchTerm: string; 67 | inStock: boolean; 68 | sortBy: 'name' | 'category' | 'quantity' | 'dateAdded'; 69 | sortDirection: 'asc' | 'desc'; 70 | status: ('in-stock' | 'in-use')[]; 71 | conditions: ('new' | 'good' | 'fair' | 'poor' | 'broken' | 'needs-repair')[]; 72 | } 73 | 74 | export interface BuildNote { 75 | id: string; 76 | title: string; 77 | description: string; 78 | status: 'planning' | 'in-progress' | 'completed' | 'archived'; 79 | imageUrls: string[]; 80 | dateCreated: string; 81 | lastModified?: string; 82 | partsUsed: BuildPart[]; 83 | totalCost: number; 84 | notes: BuildNoteEntry[]; 85 | specs?: { 86 | weight?: number; 87 | size?: string; 88 | motorKv?: number; 89 | batteryConfig?: string; 90 | flightController?: string; 91 | vtx?: string; 92 | [key: string]: string | number | undefined; 93 | }; 94 | } 95 | 96 | export interface BuildPart { 97 | partId: string; 98 | quantity: number; 99 | notes?: string; 100 | } 101 | 102 | export interface BuildNoteEntry { 103 | id: string; 104 | content: string; 105 | imageUrls: string[]; 106 | dateAdded: string; 107 | type: 'note' | 'issue' | 'modification' | 'achievement'; 108 | } 109 | 110 | export interface GalleryItem { 111 | id: string; 112 | title: string; 113 | description: string; 114 | imageUrls: string[]; 115 | dateAdded: string; 116 | tags: string[]; 117 | specs?: { 118 | weight?: number; 119 | size?: string; 120 | motorKv?: number; 121 | batteryConfig?: string; 122 | flightController?: string; 123 | vtx?: string; 124 | [key: string]: string | number | undefined; 125 | }; 126 | } 127 | 128 | export interface Link { 129 | id: string; 130 | title: string; 131 | url: string; 132 | description?: string; 133 | category: 'website' | 'youtube' | 'blog' | 'store' | 'other'; 134 | tags: string[]; 135 | dateAdded: string; 136 | lastVisited?: string; 137 | isFavorite: boolean; 138 | } -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useLocation, useNavigate } from 'react-router-dom'; 3 | import { Search, Bell, Settings } from 'lucide-react'; 4 | import { useInventoryStore } from '../store/inventoryStore'; 5 | 6 | const Header: React.FC = () => { 7 | const location = useLocation(); 8 | const navigate = useNavigate(); 9 | const [searchTerm, setSearchTerm] = useState(''); 10 | const { setFilterOptions } = useInventoryStore(); 11 | 12 | // Get the page title based on the current route 13 | const getPageTitle = () => { 14 | switch (location.pathname) { 15 | case '/': 16 | return 'Dashboard'; 17 | case '/inventory': 18 | return 'Inventory'; 19 | case '/categories': 20 | return 'Categories'; 21 | case '/storage': 22 | return 'Storage Locations'; 23 | case '/builds': 24 | return 'Build Notes'; 25 | case '/gallery': 26 | return 'Gallery'; 27 | case '/links': 28 | return 'Links'; 29 | case '/todo': 30 | return 'Things to Do'; 31 | case '/liquid-demo': 32 | return 'Liquid Glass Demo'; 33 | case '/settings': 34 | return 'Settings'; 35 | default: 36 | if (location.pathname.startsWith('/parts/')) { 37 | return 'Part Details'; 38 | } 39 | if (location.pathname.startsWith('/builds/')) { 40 | return 'Build Details'; 41 | } 42 | if (location.pathname.startsWith('/gallery/')) { 43 | return 'Gallery Details'; 44 | } 45 | return 'Drone Parts Inventory'; 46 | } 47 | }; 48 | 49 | const handleSearch = (e: React.FormEvent) => { 50 | e.preventDefault(); 51 | 52 | // Update the search filter in the inventory store 53 | setFilterOptions({ searchTerm }); 54 | 55 | // Navigate to inventory page if not already there 56 | if (location.pathname !== '/inventory') { 57 | navigate('/inventory'); 58 | } 59 | }; 60 | 61 | const handleSearchChange = (e: React.ChangeEvent) => { 62 | const value = e.target.value; 63 | setSearchTerm(value); 64 | 65 | // Real-time search - update filter as user types 66 | setFilterOptions({ searchTerm: value }); 67 | 68 | // Navigate to inventory if searching and not already there 69 | if (value && location.pathname !== '/inventory') { 70 | navigate('/inventory'); 71 | } 72 | }; 73 | 74 | return ( 75 |
76 |

{getPageTitle()}

77 | 78 |
79 |
80 | 87 | 88 | 89 | 90 | 93 | 94 | 97 |
98 |
99 | ); 100 | }; 101 | 102 | export default Header; -------------------------------------------------------------------------------- /src/components/ui/Toaster.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | import { CheckCircle, AlertCircle, Info, X } from 'lucide-react'; 4 | 5 | export type ToastType = 'success' | 'error' | 'info'; 6 | 7 | interface Toast { 8 | id: string; 9 | type: ToastType; 10 | message: string; 11 | } 12 | 13 | interface ToasterContextType { 14 | addToast: (type: ToastType, message: string) => void; 15 | } 16 | 17 | const ToasterContext = React.createContext(undefined); 18 | 19 | export const useToaster = () => { 20 | const context = React.useContext(ToasterContext); 21 | if (!context) { 22 | throw new Error('useToaster must be used within a ToasterProvider'); 23 | } 24 | return context; 25 | }; 26 | 27 | export const ToasterProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 28 | const [toasts, setToasts] = useState([]); 29 | 30 | const addToast = (type: ToastType, message: string) => { 31 | const id = Math.random().toString(36).substring(2, 9); 32 | setToasts((prev) => [...prev, { id, type, message }]); 33 | 34 | // Auto remove toast after 5 seconds 35 | setTimeout(() => { 36 | setToasts((prev) => prev.filter((toast) => toast.id !== id)); 37 | }, 5000); 38 | }; 39 | 40 | return ( 41 | 42 | {children} 43 | {typeof document !== 'undefined' && ( 44 | setToasts(prev => prev.filter(toast => toast.id !== id))} /> 45 | )} 46 | 47 | ); 48 | }; 49 | 50 | interface ToasterProps { 51 | toasts: Toast[]; 52 | removeToast: (id: string) => void; 53 | } 54 | 55 | const Toaster: React.FC = ({ toasts = [], removeToast }) => { 56 | const [mounted, setMounted] = useState(false); 57 | 58 | useEffect(() => { 59 | setMounted(true); 60 | return () => setMounted(false); 61 | }, []); 62 | 63 | if (!mounted || !Array.isArray(toasts)) return null; 64 | 65 | const getIcon = (type: ToastType) => { 66 | switch (type) { 67 | case 'success': 68 | return ; 69 | case 'error': 70 | return ; 71 | case 'info': 72 | return ; 73 | } 74 | }; 75 | 76 | const getBgColor = (type: ToastType) => { 77 | switch (type) { 78 | case 'success': 79 | return 'bg-green-100 dark:bg-green-900/20'; 80 | case 'error': 81 | return 'bg-red-100 dark:bg-red-900/20'; 82 | case 'info': 83 | return 'bg-blue-100 dark:bg-blue-900/20'; 84 | } 85 | }; 86 | 87 | const getBorderColor = (type: ToastType) => { 88 | switch (type) { 89 | case 'success': 90 | return 'border-l-4 border-green-500'; 91 | case 'error': 92 | return 'border-l-4 border-red-500'; 93 | case 'info': 94 | return 'border-l-4 border-blue-500'; 95 | } 96 | }; 97 | 98 | return createPortal( 99 |
100 | {toasts.map((toast) => ( 101 |
106 |
{getIcon(toast.type)}
107 |
{toast.message}
108 | 114 |
115 | ))} 116 |
, 117 | document.body 118 | ); 119 | }; 120 | 121 | export default Toaster; 122 | 123 | export { Toaster } -------------------------------------------------------------------------------- /src/store/themeStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | interface ThemeState { 4 | theme: string; 5 | customColors: { 6 | bgPrimary: string; 7 | bgSecondary: string; 8 | textPrimary: string; 9 | textSecondary: string; 10 | borderColor: string; 11 | accentPrimary: string; 12 | accentSecondary: string; 13 | }; 14 | setTheme: (theme: string) => void; 15 | setCustomColors: (colors: Partial) => void; 16 | } 17 | 18 | // Get initial theme from localStorage 19 | const getInitialTheme = () => { 20 | const savedTheme = localStorage.getItem('theme'); 21 | console.log('Initial theme from localStorage:', savedTheme); 22 | return savedTheme || 'dark'; 23 | }; 24 | 25 | // Get initial custom colors from localStorage 26 | const getInitialCustomColors = () => { 27 | const savedColors = localStorage.getItem('customThemeColors'); 28 | if (savedColors) { 29 | try { 30 | return JSON.parse(savedColors); 31 | } catch (error) { 32 | console.error('Error parsing custom colors:', error); 33 | } 34 | } 35 | return { 36 | bgPrimary: '#1a1a1a', 37 | bgSecondary: '#2d2d2d', 38 | textPrimary: '#ffffff', 39 | textSecondary: '#b0b0b0', 40 | borderColor: '#404040', 41 | accentPrimary: '#3b82f6', 42 | accentSecondary: '#10b981' 43 | }; 44 | }; 45 | 46 | export const useThemeStore = create((set, get) => ({ 47 | theme: getInitialTheme(), 48 | customColors: getInitialCustomColors(), 49 | setTheme: (theme) => { 50 | console.log('Setting theme to:', theme); 51 | console.log('Previous theme was:', get().theme); 52 | 53 | // Save to localStorage 54 | localStorage.setItem('theme', theme); 55 | console.log('Theme saved to localStorage:', theme); 56 | 57 | // Update document attribute 58 | document.documentElement.setAttribute('data-theme', theme); 59 | console.log('Document data-theme attribute set to:', theme); 60 | 61 | // If custom theme, apply custom colors 62 | if (theme === 'custom') { 63 | const { customColors } = get(); 64 | applyCustomColors(customColors); 65 | } 66 | 67 | // Update meta theme-color 68 | const themeColors = { 69 | light: '#ffffff', 70 | dark: '#131419', 71 | midnight: '#1a2f1a', 72 | cyberpunk: '#18181b', 73 | matrix: '#0c0c0c', 74 | blackOrange: '#000000', 75 | sunset: '#2d1b3d', 76 | summer: '#ff6b6b', 77 | custom: get().customColors.bgPrimary 78 | }; 79 | 80 | const metaThemeColor = document.querySelector('meta[name="theme-color"]'); 81 | if (metaThemeColor) { 82 | metaThemeColor.setAttribute('content', themeColors[theme as keyof typeof themeColors] || '#131419'); 83 | console.log('Meta theme-color updated to:', themeColors[theme as keyof typeof themeColors] || '#131419'); 84 | } 85 | 86 | // Force a re-render by updating the store 87 | set({ theme }); 88 | 89 | console.log('Theme updated to:', theme); 90 | console.log('Current document data-theme:', document.documentElement.getAttribute('data-theme')); 91 | console.log('Current localStorage theme:', localStorage.getItem('theme')); 92 | }, 93 | setCustomColors: (colors) => { 94 | const currentColors = get().customColors; 95 | const newColors = { ...currentColors, ...colors }; 96 | 97 | // Save to localStorage 98 | localStorage.setItem('customThemeColors', JSON.stringify(newColors)); 99 | 100 | // Update store 101 | set({ customColors: newColors }); 102 | 103 | // If custom theme is active, apply the new colors immediately 104 | if (get().theme === 'custom') { 105 | applyCustomColors(newColors); 106 | } 107 | 108 | console.log('Custom colors updated:', newColors); 109 | }, 110 | })); 111 | 112 | // Helper function to apply custom colors to CSS custom properties 113 | const applyCustomColors = (colors: ThemeState['customColors']) => { 114 | const root = document.documentElement; 115 | 116 | // Convert hex colors to RGB for rgba usage 117 | const hexToRgb = (hex: string) => { 118 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 119 | return result ? { 120 | r: parseInt(result[1], 16), 121 | g: parseInt(result[2], 16), 122 | b: parseInt(result[3], 16) 123 | } : null; 124 | }; 125 | 126 | const accentPrimaryRgb = hexToRgb(colors.accentPrimary); 127 | 128 | // Set CSS custom properties 129 | root.style.setProperty('--bg-primary', colors.bgPrimary); 130 | root.style.setProperty('--bg-secondary', colors.bgSecondary); 131 | root.style.setProperty('--text-primary', colors.textPrimary); 132 | root.style.setProperty('--text-secondary', colors.textSecondary); 133 | root.style.setProperty('--border-color', colors.borderColor); 134 | root.style.setProperty('--accent-primary', colors.accentPrimary); 135 | root.style.setProperty('--accent-secondary', colors.accentSecondary); 136 | 137 | // Set RGB values for rgba usage in liquid glass effects 138 | if (accentPrimaryRgb) { 139 | root.style.setProperty('--accent-primary-rgb', `${accentPrimaryRgb.r}, ${accentPrimaryRgb.g}, ${accentPrimaryRgb.b}`); 140 | } 141 | 142 | console.log('Custom colors applied to CSS:', colors); 143 | }; -------------------------------------------------------------------------------- /src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { NavLink, Link, useNavigate } from 'react-router-dom'; 3 | import { 4 | LayoutDashboard, Package, Tags, CheckSquare, Settings, 5 | LogOut, Wrench, Camera, Link as LinkIcon, Menu, X, MapPin, Plane 6 | } from 'lucide-react'; 7 | 8 | const Sidebar: React.FC = () => { 9 | const navigate = useNavigate(); 10 | const [isCollapsed, setIsCollapsed] = useState(() => { 11 | const saved = localStorage.getItem('sidebarCollapsed'); 12 | return saved ? JSON.parse(saved) : false; 13 | }); 14 | const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); 15 | 16 | const navItems = [ 17 | { icon: , text: 'Dashboard', path: '/' }, 18 | { icon: , text: 'Inventory', path: '/inventory' }, 19 | { icon: , text: 'Categories', path: '/categories' }, 20 | { icon: , text: 'Storage Locations', path: '/storage' }, 21 | { icon: , text: 'Build Notes', path: '/builds' }, 22 | { icon: , text: 'Flight Log', path: '/flight-log' }, 23 | { icon: , text: 'Gallery', path: '/gallery' }, 24 | { icon: , text: 'Links', path: '/links' }, 25 | { icon: , text: 'Things to Do', path: '/todo' }, 26 | { icon: , text: 'Settings', path: '/settings' } 27 | ]; 28 | 29 | useEffect(() => { 30 | localStorage.setItem('sidebarCollapsed', JSON.stringify(isCollapsed)); 31 | }, [isCollapsed]); 32 | 33 | const toggleSidebar = () => { 34 | setIsCollapsed(!isCollapsed); 35 | setIsMobileMenuOpen(false); 36 | }; 37 | 38 | const toggleMobileMenu = () => { 39 | setIsMobileMenuOpen(!isMobileMenuOpen); 40 | setIsCollapsed(false); 41 | }; 42 | 43 | const handleExit = () => { 44 | navigate('/'); 45 | setIsMobileMenuOpen(false); 46 | }; 47 | 48 | return ( 49 | <> 50 | {/* Mobile Menu Button */} 51 | 57 | 58 | {/* Mobile Menu Overlay */} 59 | {isMobileMenuOpen && ( 60 |
setIsMobileMenuOpen(false)} 63 | /> 64 | )} 65 | 66 | 136 | 137 | ); 138 | }; 139 | 140 | export default Sidebar; -------------------------------------------------------------------------------- /src/store/storageLocationStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { StorageLocation } from '../models/types'; 4 | 5 | // Load saved data from localStorage 6 | const loadFromStorage = (key: string, defaultValue: T): T => { 7 | try { 8 | const saved = localStorage.getItem(key); 9 | return saved ? JSON.parse(saved) : defaultValue; 10 | } catch (error) { 11 | console.error(`Error loading ${key} from localStorage:`, error); 12 | return defaultValue; 13 | } 14 | }; 15 | 16 | // Save data to localStorage 17 | const saveToStorage = (key: string, value: any) => { 18 | try { 19 | localStorage.setItem(key, JSON.stringify(value)); 20 | } catch (error) { 21 | console.error(`Error saving ${key} to localStorage:`, error); 22 | } 23 | }; 24 | 25 | // Initial sample data 26 | const sampleLocations: StorageLocation[] = [ 27 | { 28 | id: '1', 29 | name: 'Shelf A1', 30 | description: 'Top shelf, left side - Motors and ESCs', 31 | type: 'shelf', 32 | capacity: 50, 33 | currentItems: 12, 34 | dateAdded: new Date().toISOString() 35 | }, 36 | { 37 | id: '2', 38 | name: 'Drawer B2', 39 | description: 'Second drawer - Small electronics', 40 | type: 'drawer', 41 | capacity: 30, 42 | currentItems: 8, 43 | dateAdded: new Date().toISOString() 44 | }, 45 | { 46 | id: '3', 47 | name: 'Box C3', 48 | description: 'Storage box - Flight controllers', 49 | type: 'box', 50 | capacity: 20, 51 | currentItems: 5, 52 | dateAdded: new Date().toISOString() 53 | } 54 | ]; 55 | 56 | // Load initial data from localStorage or use sample data 57 | const initialLocations = loadFromStorage('storageLocations', sampleLocations); 58 | 59 | interface StorageLocationState { 60 | locations: StorageLocation[]; 61 | filteredLocations: StorageLocation[]; 62 | filterOptions: { 63 | searchTerm: string; 64 | types: ('shelf' | 'drawer' | 'box' | 'cabinet' | 'room' | 'other')[]; 65 | sortBy: 'name' | 'type' | 'capacity' | 'dateAdded'; 66 | sortDirection: 'asc' | 'desc'; 67 | }; 68 | 69 | // Actions 70 | addLocation: (location: Omit) => void; 71 | updateLocation: (id: string, locationData: Partial) => void; 72 | deleteLocation: (id: string) => void; 73 | getLocation: (id: string) => StorageLocation | undefined; 74 | updateLocationItemCount: (locationName: string, change: number) => void; 75 | 76 | setFilterOptions: (options: Partial) => void; 77 | applyFilters: () => void; 78 | } 79 | 80 | export const useStorageLocationStore = create((set, get) => ({ 81 | locations: initialLocations, 82 | filteredLocations: initialLocations, 83 | filterOptions: { 84 | searchTerm: '', 85 | types: ['shelf', 'drawer', 'box', 'cabinet', 'room', 'other'], 86 | sortBy: 'name', 87 | sortDirection: 'asc' 88 | }, 89 | 90 | // Actions 91 | addLocation: (location) => { 92 | const newLocation: StorageLocation = { 93 | ...location, 94 | id: uuidv4(), 95 | dateAdded: new Date().toISOString(), 96 | currentItems: 0 97 | }; 98 | set((state) => { 99 | const newLocations = [...state.locations, newLocation]; 100 | saveToStorage('storageLocations', newLocations); 101 | return { locations: newLocations }; 102 | }); 103 | get().applyFilters(); 104 | }, 105 | 106 | updateLocation: (id, locationData) => { 107 | set((state) => { 108 | const newLocations = state.locations.map((location) => 109 | location.id === id 110 | ? { ...location, ...locationData, lastModified: new Date().toISOString() } 111 | : location 112 | ); 113 | saveToStorage('storageLocations', newLocations); 114 | return { locations: newLocations }; 115 | }); 116 | get().applyFilters(); 117 | }, 118 | 119 | deleteLocation: (id) => { 120 | set((state) => { 121 | const newLocations = state.locations.filter((location) => location.id !== id); 122 | saveToStorage('storageLocations', newLocations); 123 | return { locations: newLocations }; 124 | }); 125 | get().applyFilters(); 126 | }, 127 | 128 | getLocation: (id) => { 129 | return get().locations.find((location) => location.id === id); 130 | }, 131 | 132 | updateLocationItemCount: (locationName, change) => { 133 | set((state) => { 134 | const newLocations = state.locations.map((location) => 135 | location.name === locationName 136 | ? { 137 | ...location, 138 | currentItems: Math.max(0, (location.currentItems || 0) + change), 139 | lastModified: new Date().toISOString() 140 | } 141 | : location 142 | ); 143 | saveToStorage('storageLocations', newLocations); 144 | return { locations: newLocations }; 145 | }); 146 | get().applyFilters(); 147 | }, 148 | 149 | setFilterOptions: (options) => { 150 | set((state) => ({ 151 | filterOptions: { ...state.filterOptions, ...options } 152 | })); 153 | get().applyFilters(); 154 | }, 155 | 156 | applyFilters: () => { 157 | const { locations, filterOptions } = get(); 158 | 159 | let filtered = [...locations]; 160 | 161 | // Apply search term filter 162 | if (filterOptions.searchTerm) { 163 | const term = filterOptions.searchTerm.toLowerCase(); 164 | filtered = filtered.filter((location) => 165 | location.name.toLowerCase().includes(term) || 166 | location.description?.toLowerCase().includes(term) 167 | ); 168 | } 169 | 170 | // Apply type filter 171 | if (filterOptions.types.length > 0) { 172 | filtered = filtered.filter((location) => 173 | filterOptions.types.includes(location.type) 174 | ); 175 | } 176 | 177 | // Apply sorting 178 | filtered.sort((a, b) => { 179 | let valueA: any; 180 | let valueB: any; 181 | 182 | switch (filterOptions.sortBy) { 183 | case 'name': 184 | valueA = a.name.toLowerCase(); 185 | valueB = b.name.toLowerCase(); 186 | break; 187 | case 'type': 188 | valueA = a.type.toLowerCase(); 189 | valueB = b.type.toLowerCase(); 190 | break; 191 | case 'capacity': 192 | valueA = a.capacity || 0; 193 | valueB = b.capacity || 0; 194 | break; 195 | case 'dateAdded': 196 | valueA = new Date(a.dateAdded).getTime(); 197 | valueB = new Date(b.dateAdded).getTime(); 198 | break; 199 | default: 200 | valueA = a.name.toLowerCase(); 201 | valueB = b.name.toLowerCase(); 202 | } 203 | 204 | if (filterOptions.sortDirection === 'asc') { 205 | return valueA > valueB ? 1 : -1; 206 | } else { 207 | return valueA < valueB ? 1 : -1; 208 | } 209 | }); 210 | 211 | set({ filteredLocations: filtered }); 212 | } 213 | })); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /src/store/todoStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { TodoItem } from '../models/types'; 4 | 5 | const STORAGE_KEY = 'quadparts_todos_data'; 6 | 7 | interface TodoState { 8 | todos: TodoItem[]; 9 | filteredTodos: TodoItem[]; 10 | filterOptions: { 11 | completed: boolean | null; 12 | priority: ('low' | 'medium' | 'high')[]; 13 | searchTerm: string; 14 | }; 15 | 16 | // Actions 17 | addTodo: (todo: Omit) => void; 18 | updateTodo: (id: string, todoData: Partial) => void; 19 | deleteTodo: (id: string) => void; 20 | toggleTodoComplete: (id: string) => void; 21 | 22 | setFilterOptions: (options: Partial) => void; 23 | applyFilters: () => void; 24 | clearCompleted: () => void; 25 | } 26 | 27 | // Sample data 28 | const sampleTodos: TodoItem[] = [ 29 | { 30 | id: '1', 31 | title: 'Order more 1106 motors', 32 | description: 'Need at least 10 more for upcoming micro builds', 33 | completed: false, 34 | priority: 'high', 35 | dateCreated: new Date().toISOString(), 36 | dateDue: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), 37 | }, 38 | { 39 | id: '2', 40 | title: 'Test new flight controller', 41 | description: 'Set up the new Matek F722 and test all outputs', 42 | completed: false, 43 | priority: 'medium', 44 | dateCreated: new Date().toISOString(), 45 | }, 46 | { 47 | id: '3', 48 | title: 'Organize storage area', 49 | description: 'Add more bins and label everything', 50 | completed: true, 51 | priority: 'low', 52 | dateCreated: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), 53 | dateCompleted: new Date().toISOString() 54 | } 55 | ]; 56 | 57 | // Load saved data from localStorage 58 | const loadSavedData = () => { 59 | try { 60 | const savedData = localStorage.getItem(STORAGE_KEY); 61 | if (savedData) { 62 | const parsed = JSON.parse(savedData); 63 | 64 | // Handle different data structures 65 | let todos; 66 | if (Array.isArray(parsed)) { 67 | // If the data is directly an array 68 | todos = parsed; 69 | } else if (parsed && typeof parsed === 'object') { 70 | // If the data is wrapped in an object 71 | if (Array.isArray(parsed.todos)) { 72 | todos = parsed.todos; 73 | } else if (Array.isArray(parsed.data)) { 74 | todos = parsed.data; 75 | } else { 76 | console.warn('Unexpected todos data structure:', parsed); 77 | return { todos: sampleTodos }; 78 | } 79 | } else { 80 | console.warn('Unexpected todos data structure:', parsed); 81 | return { todos: sampleTodos }; 82 | } 83 | 84 | console.log(`Loaded ${todos.length} todos from localStorage`); 85 | return { todos }; 86 | } 87 | } catch (error) { 88 | console.error('Error loading todos data from localStorage:', error); 89 | } 90 | return { todos: sampleTodos }; 91 | }; 92 | 93 | const { todos: savedTodos } = loadSavedData(); 94 | 95 | export const useTodoStore = create((set, get) => ({ 96 | todos: savedTodos, 97 | filteredTodos: savedTodos, 98 | filterOptions: { 99 | completed: null, // null means show all, true means show completed only, false means show incomplete only 100 | priority: ['low', 'medium', 'high'], 101 | searchTerm: '', 102 | }, 103 | 104 | // Actions 105 | addTodo: (todo) => { 106 | const newTodo: TodoItem = { 107 | ...todo, 108 | id: uuidv4(), 109 | completed: false, 110 | dateCreated: new Date().toISOString() 111 | }; 112 | set((state) => ({ 113 | todos: [...state.todos, newTodo] 114 | })); 115 | get().applyFilters(); 116 | 117 | // Save to localStorage 118 | const { todos } = get(); 119 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ todos })); 120 | }, 121 | 122 | updateTodo: (id, todoData) => { 123 | set((state) => ({ 124 | todos: state.todos.map((todo) => 125 | todo.id === id 126 | ? { ...todo, ...todoData } 127 | : todo 128 | ) 129 | })); 130 | get().applyFilters(); 131 | 132 | // Save to localStorage 133 | const { todos } = get(); 134 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ todos })); 135 | }, 136 | 137 | deleteTodo: (id) => { 138 | set((state) => ({ 139 | todos: state.todos.filter((todo) => todo.id !== id) 140 | })); 141 | get().applyFilters(); 142 | 143 | // Save to localStorage 144 | const { todos } = get(); 145 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ todos })); 146 | }, 147 | 148 | toggleTodoComplete: (id) => { 149 | set((state) => ({ 150 | todos: state.todos.map((todo) => 151 | todo.id === id 152 | ? { 153 | ...todo, 154 | completed: !todo.completed, 155 | dateCompleted: !todo.completed ? new Date().toISOString() : undefined 156 | } 157 | : todo 158 | ) 159 | })); 160 | get().applyFilters(); 161 | 162 | // Save to localStorage 163 | const { todos } = get(); 164 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ todos })); 165 | }, 166 | 167 | setFilterOptions: (options) => { 168 | set((state) => ({ 169 | filterOptions: { ...state.filterOptions, ...options } 170 | })); 171 | get().applyFilters(); 172 | }, 173 | 174 | applyFilters: () => { 175 | const { todos, filterOptions } = get(); 176 | 177 | let filtered = [...todos]; 178 | 179 | // Filter by completion status 180 | if (filterOptions.completed !== null) { 181 | filtered = filtered.filter((todo) => todo.completed === filterOptions.completed); 182 | } 183 | 184 | // Filter by priority 185 | filtered = filtered.filter((todo) => 186 | filterOptions.priority.includes(todo.priority) 187 | ); 188 | 189 | // Filter by search term 190 | if (filterOptions.searchTerm) { 191 | const term = filterOptions.searchTerm.toLowerCase(); 192 | filtered = filtered.filter((todo) => 193 | todo.title.toLowerCase().includes(term) || 194 | (todo.description && todo.description.toLowerCase().includes(term)) 195 | ); 196 | } 197 | 198 | // Sort by priority and due date 199 | filtered.sort((a, b) => { 200 | const priorityMap = { high: 0, medium: 1, low: 2 }; 201 | const priorityDiff = priorityMap[a.priority] - priorityMap[b.priority]; 202 | 203 | if (priorityDiff !== 0) return priorityDiff; 204 | 205 | // If same priority, sort by due date (if available) 206 | if (a.dateDue && b.dateDue) { 207 | return new Date(a.dateDue).getTime() - new Date(b.dateDue).getTime(); 208 | } else if (a.dateDue) { 209 | return -1; 210 | } else if (b.dateDue) { 211 | return 1; 212 | } 213 | 214 | // Otherwise sort by creation date 215 | return new Date(a.dateCreated).getTime() - new Date(b.dateCreated).getTime(); 216 | }); 217 | 218 | set({ filteredTodos: filtered }); 219 | }, 220 | 221 | clearCompleted: () => { 222 | set((state) => ({ 223 | todos: state.todos.filter((todo) => !todo.completed) 224 | })); 225 | get().applyFilters(); 226 | 227 | // Save to localStorage 228 | const { todos } = get(); 229 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ todos })); 230 | } 231 | })); -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; 3 | import Sidebar from './components/Sidebar'; 4 | import Header from './components/Header'; 5 | import Dashboard from './pages/Dashboard'; 6 | import Inventory from './pages/Inventory'; 7 | import Categories from './pages/Categories'; 8 | import StorageLocations from './pages/StorageLocations'; 9 | import BuildNotes from './pages/BuildNotes'; 10 | import BuildNoteDetail from './pages/BuildNoteDetail'; 11 | import Gallery from './pages/Gallery'; 12 | import GalleryItemDetail from './pages/GalleryItemDetail'; 13 | import Links from './pages/Links'; 14 | import TodoList from './pages/TodoList'; 15 | import PartDetails from './pages/PartDetails'; 16 | import Settings from './pages/Settings'; 17 | import FlightLog from './pages/FlightLog'; 18 | import LiquidGlassDemo from './pages/LiquidGlassDemo'; 19 | import { useThemeStore } from './store/themeStore'; 20 | 21 | // Error Boundary Component 22 | class ErrorBoundary extends React.Component< 23 | { children: React.ReactNode }, 24 | { hasError: boolean; error?: Error } 25 | > { 26 | constructor(props: { children: React.ReactNode }) { 27 | super(props); 28 | this.state = { hasError: false }; 29 | } 30 | 31 | static getDerivedStateFromError(error: Error) { 32 | return { hasError: true, error }; 33 | } 34 | 35 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 36 | console.error('App Error Boundary caught an error:', error, errorInfo); 37 | } 38 | 39 | render() { 40 | if (this.state.hasError) { 41 | return ( 42 |
43 |
44 |

Something went wrong

45 |

46 | The application encountered an error. This might be due to corrupted data from a recent import. 47 |

48 |
49 |

Error: {this.state.error?.message}

50 |
51 |
52 | 62 | 68 |
69 |
70 |
71 | ); 72 | } 73 | 74 | return this.props.children; 75 | } 76 | } 77 | 78 | function App() { 79 | const { theme, setTheme, customColors } = useThemeStore(); 80 | const [isLoading, setIsLoading] = useState(true); 81 | 82 | useEffect(() => { 83 | try { 84 | // Initialize theme on app startup 85 | const savedTheme = localStorage.getItem('theme') || 'dark'; 86 | console.log('Initializing theme:', savedTheme); 87 | 88 | // Set the theme attribute immediately 89 | document.documentElement.setAttribute('data-theme', savedTheme); 90 | 91 | // If custom theme, apply custom colors 92 | if (savedTheme === 'custom') { 93 | const savedColors = localStorage.getItem('customThemeColors'); 94 | if (savedColors) { 95 | try { 96 | const colors = JSON.parse(savedColors); 97 | // Apply custom colors to CSS 98 | const root = document.documentElement; 99 | const hexToRgb = (hex: string) => { 100 | const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 101 | return result ? { 102 | r: parseInt(result[1], 16), 103 | g: parseInt(result[2], 16), 104 | b: parseInt(result[3], 16) 105 | } : null; 106 | }; 107 | 108 | const accentPrimaryRgb = hexToRgb(colors.accentPrimary); 109 | 110 | root.style.setProperty('--bg-primary', colors.bgPrimary); 111 | root.style.setProperty('--bg-secondary', colors.bgSecondary); 112 | root.style.setProperty('--text-primary', colors.textPrimary); 113 | root.style.setProperty('--text-secondary', colors.textSecondary); 114 | root.style.setProperty('--border-color', colors.borderColor); 115 | root.style.setProperty('--accent-primary', colors.accentPrimary); 116 | root.style.setProperty('--accent-secondary', colors.accentSecondary); 117 | 118 | if (accentPrimaryRgb) { 119 | root.style.setProperty('--accent-primary-rgb', `${accentPrimaryRgb.r}, ${accentPrimaryRgb.g}, ${accentPrimaryRgb.b}`); 120 | } 121 | 122 | console.log('Custom colors applied on startup:', colors); 123 | } catch (error) { 124 | console.error('Error applying custom colors on startup:', error); 125 | } 126 | } 127 | } 128 | 129 | // Update the store if needed 130 | if (theme !== savedTheme) { 131 | setTheme(savedTheme); 132 | } 133 | 134 | setIsLoading(false); 135 | } catch (error) { 136 | console.error('Error during app initialization:', error); 137 | setIsLoading(false); 138 | } 139 | }, []); // Run only on mount 140 | 141 | useEffect(() => { 142 | try { 143 | console.log('App theme changed to:', theme); 144 | document.documentElement.setAttribute('data-theme', theme); 145 | 146 | // Force a re-render by updating body class 147 | document.body.className = `theme-${theme}`; 148 | } catch (error) { 149 | console.error('Error updating theme:', error); 150 | } 151 | }, [theme]); 152 | 153 | if (isLoading) { 154 | return ( 155 |
156 |
157 |
158 |

Loading application...

159 |
160 |
161 | ); 162 | } 163 | 164 | return ( 165 | 166 | 167 |
168 | {/* Liquid background effect */} 169 |
170 | 171 | 172 |
173 |
174 |
175 | 176 | } /> 177 | } /> 178 | } /> 179 | } /> 180 | } /> 181 | } /> 182 | } /> 183 | } /> 184 | } /> 185 | } /> 186 | } /> 187 | } /> 188 | } /> 189 | } /> 190 | 191 |
192 |
193 |
194 |
195 |
196 | ); 197 | } 198 | 199 | export default App -------------------------------------------------------------------------------- /src/store/buildStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { BuildNote, BuildNoteEntry } from '../models/types'; 4 | 5 | const STORAGE_KEY = 'quadparts_builds_data'; 6 | 7 | interface BuildState { 8 | builds: BuildNote[]; 9 | filteredBuilds: BuildNote[]; 10 | filterOptions: { 11 | status: ('planning' | 'in-progress' | 'completed' | 'archived')[]; 12 | searchTerm: string; 13 | }; 14 | 15 | // Actions 16 | addBuild: (build: Omit) => void; 17 | updateBuild: (id: string, buildData: Partial) => void; 18 | deleteBuild: (id: string) => void; 19 | getBuild: (id: string) => BuildNote | undefined; 20 | 21 | addBuildNote: (buildId: string, note: Omit) => void; 22 | updateBuildNote: (buildId: string, noteId: string, noteData: Partial) => void; 23 | deleteBuildNote: (buildId: string, noteId: string) => void; 24 | 25 | setFilterOptions: (options: Partial) => void; 26 | applyFilters: () => void; 27 | } 28 | 29 | // Sample data 30 | const sampleBuilds: BuildNote[] = [ 31 | { 32 | id: '1', 33 | title: '5" Freestyle Build', 34 | description: 'Custom 5-inch freestyle quad with DJI digital system', 35 | status: 'completed', 36 | imageUrls: ['https://images.pexels.com/photos/442587/pexels-photo-442587.jpeg?auto=compress&cs=tinysrgb&w=1280'], 37 | dateCreated: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), 38 | partsUsed: [], 39 | totalCost: 549.99, 40 | notes: [ 41 | { 42 | id: '1-1', 43 | content: 'Initial build complete. Maiden flight successful!', 44 | imageUrls: ['https://images.pexels.com/photos/442589/pexels-photo-442589.jpeg?auto=compress&cs=tinysrgb&w=1280'], 45 | dateAdded: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000).toISOString(), 46 | type: 'achievement' 47 | } 48 | ], 49 | specs: { 50 | weight: 650, 51 | size: '5 inch', 52 | motorKv: 1900, 53 | batteryConfig: '6S 1300mAh', 54 | flightController: 'Matek F722-SE', 55 | vtx: 'DJI Air Unit' 56 | } 57 | }, 58 | { 59 | id: '2', 60 | title: 'Micro Long Range', 61 | description: 'Ultra-efficient 3" LR build for maximum flight time', 62 | status: 'in-progress', 63 | imageUrls: ['https://images.pexels.com/photos/744366/pexels-photo-744366.jpeg?auto=compress&cs=tinysrgb&w=1280'], 64 | dateCreated: new Date().toISOString(), 65 | partsUsed: [], 66 | totalCost: 299.99, 67 | notes: [], 68 | specs: { 69 | weight: 180, 70 | size: '3 inch', 71 | motorKv: 1404, 72 | batteryConfig: '4S 850mAh', 73 | flightController: 'HGLRC Zeus F722', 74 | vtx: 'RushFPV Tank Ultimate' 75 | } 76 | } 77 | ]; 78 | 79 | // Load saved data from localStorage 80 | const loadSavedData = () => { 81 | try { 82 | const savedData = localStorage.getItem(STORAGE_KEY); 83 | if (savedData) { 84 | const parsed = JSON.parse(savedData); 85 | 86 | // Handle different data structures 87 | let builds; 88 | if (Array.isArray(parsed)) { 89 | // If the data is directly an array 90 | builds = parsed; 91 | } else if (parsed && typeof parsed === 'object' && Array.isArray(parsed.builds)) { 92 | // If the data is wrapped in an object with 'builds' property 93 | builds = parsed.builds; 94 | } else { 95 | console.warn('Unexpected builds data structure:', parsed); 96 | return { builds: sampleBuilds }; 97 | } 98 | 99 | // Ensure dates are properly parsed 100 | const parsedBuilds = builds.map((build: any) => ({ 101 | ...build, 102 | dateCreated: new Date(build.dateCreated).toISOString(), 103 | lastModified: build.lastModified ? new Date(build.lastModified).toISOString() : undefined, 104 | notes: Array.isArray(build.notes) ? build.notes.map((note: any) => ({ 105 | ...note, 106 | dateAdded: new Date(note.dateAdded).toISOString() 107 | })) : [] 108 | })); 109 | 110 | console.log(`Loaded ${parsedBuilds.length} builds from localStorage`); 111 | return { builds: parsedBuilds }; 112 | } 113 | } catch (error) { 114 | console.error('Error loading builds data from localStorage:', error); 115 | } 116 | return { builds: sampleBuilds }; 117 | }; 118 | 119 | const { builds: savedBuilds } = loadSavedData(); 120 | 121 | export const useBuildStore = create((set, get) => ({ 122 | builds: savedBuilds, 123 | filteredBuilds: savedBuilds, 124 | filterOptions: { 125 | status: ['planning', 'in-progress', 'completed', 'archived'], 126 | searchTerm: '' 127 | }, 128 | 129 | // Actions 130 | addBuild: (build) => { 131 | const newBuild: BuildNote = { 132 | ...build, 133 | id: uuidv4(), 134 | dateCreated: new Date().toISOString(), 135 | notes: [], 136 | totalCost: 0 137 | }; 138 | set((state) => ({ 139 | builds: [...state.builds, newBuild] 140 | })); 141 | get().applyFilters(); 142 | 143 | // Save to localStorage 144 | const { builds } = get(); 145 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ builds })); 146 | }, 147 | 148 | updateBuild: (id, buildData) => { 149 | set((state) => ({ 150 | builds: state.builds.map((build) => 151 | build.id === id 152 | ? { 153 | ...build, 154 | ...buildData, 155 | lastModified: new Date().toISOString() 156 | } 157 | : build 158 | ) 159 | })); 160 | get().applyFilters(); 161 | 162 | // Save to localStorage 163 | const { builds } = get(); 164 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ builds })); 165 | }, 166 | 167 | deleteBuild: (id) => { 168 | set((state) => ({ 169 | builds: state.builds.filter((build) => build.id !== id) 170 | })); 171 | get().applyFilters(); 172 | 173 | // Save to localStorage 174 | const { builds } = get(); 175 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ builds })); 176 | }, 177 | 178 | getBuild: (id) => { 179 | return get().builds.find((build) => build.id === id); 180 | }, 181 | 182 | addBuildNote: (buildId, note) => { 183 | const newNote: BuildNoteEntry = { 184 | ...note, 185 | id: uuidv4(), 186 | dateAdded: new Date().toISOString() 187 | }; 188 | 189 | set((state) => ({ 190 | builds: state.builds.map((build) => 191 | build.id === buildId 192 | ? { 193 | ...build, 194 | notes: [...build.notes, newNote], 195 | lastModified: new Date().toISOString() 196 | } 197 | : build 198 | ) 199 | })); 200 | 201 | // Save to localStorage 202 | const { builds } = get(); 203 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ builds })); 204 | }, 205 | 206 | updateBuildNote: (buildId, noteId, noteData) => { 207 | set((state) => ({ 208 | builds: state.builds.map((build) => 209 | build.id === buildId 210 | ? { 211 | ...build, 212 | notes: build.notes.map((note) => 213 | note.id === noteId 214 | ? { ...note, ...noteData } 215 | : note 216 | ), 217 | lastModified: new Date().toISOString() 218 | } 219 | : build 220 | ) 221 | })); 222 | 223 | // Save to localStorage 224 | const { builds } = get(); 225 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ builds })); 226 | }, 227 | 228 | deleteBuildNote: (buildId, noteId) => { 229 | set((state) => ({ 230 | builds: state.builds.map((build) => 231 | build.id === buildId 232 | ? { 233 | ...build, 234 | notes: build.notes.filter((note) => note.id !== noteId), 235 | lastModified: new Date().toISOString() 236 | } 237 | : build 238 | ) 239 | })); 240 | 241 | // Save to localStorage 242 | const { builds } = get(); 243 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ builds })); 244 | }, 245 | 246 | setFilterOptions: (options) => { 247 | set((state) => ({ 248 | filterOptions: { ...state.filterOptions, ...options } 249 | })); 250 | get().applyFilters(); 251 | }, 252 | 253 | applyFilters: () => { 254 | const { builds, filterOptions } = get(); 255 | 256 | let filtered = [...builds]; 257 | 258 | // Filter by status 259 | filtered = filtered.filter((build) => 260 | filterOptions.status.includes(build.status) 261 | ); 262 | 263 | // Filter by search term 264 | if (filterOptions.searchTerm) { 265 | const term = filterOptions.searchTerm.toLowerCase(); 266 | filtered = filtered.filter((build) => 267 | build.title.toLowerCase().includes(term) || 268 | build.description.toLowerCase().includes(term) 269 | ); 270 | } 271 | 272 | // Sort by date 273 | filtered.sort((a, b) => 274 | new Date(b.dateCreated).getTime() - new Date(a.dateCreated).getTime() 275 | ); 276 | 277 | set({ filteredBuilds: filtered }); 278 | } 279 | })); -------------------------------------------------------------------------------- /src/store/galleryStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { GalleryItem } from '../models/types'; 4 | 5 | const STORAGE_KEY = 'quadparts_gallery_data'; 6 | 7 | interface GalleryState { 8 | items: GalleryItem[]; 9 | filteredItems: GalleryItem[]; 10 | customTags: string[]; 11 | filterOptions: { 12 | searchTerm: string; 13 | tags: string[]; 14 | }; 15 | 16 | // Actions 17 | addItem: (item: Omit) => void; 18 | updateItem: (id: string, itemData: Partial) => void; 19 | deleteItem: (id: string) => void; 20 | getItem: (id: string) => GalleryItem | undefined; 21 | 22 | addCustomTag: (tag: string) => void; 23 | removeCustomTag: (tag: string) => void; 24 | 25 | setFilterOptions: (options: Partial) => void; 26 | applyFilters: () => void; 27 | } 28 | 29 | // Sample data 30 | const sampleItems: GalleryItem[] = [ 31 | { 32 | id: '1', 33 | title: 'Freestyle Beast', 34 | description: 'My favorite 5" freestyle quad with amazing handling', 35 | imageUrls: [ 36 | 'https://images.pexels.com/photos/442587/pexels-photo-442587.jpeg?auto=compress&cs=tinysrgb&w=1280', 37 | 'https://images.pexels.com/photos/442589/pexels-photo-442589.jpeg?auto=compress&cs=tinysrgb&w=1280' 38 | ], 39 | dateAdded: new Date().toISOString(), 40 | tags: ['freestyle', '5inch', 'analog'], 41 | specs: { 42 | weight: 650, 43 | size: '5 inch', 44 | motorKv: 1900, 45 | batteryConfig: '6S 1300mAh', 46 | flightController: 'Matek F722-SE', 47 | vtx: 'Rush Tank Ultimate' 48 | } 49 | }, 50 | { 51 | id: '2', 52 | title: 'Micro Ripper', 53 | description: 'Ultra-light 3" build for indoor and outdoor fun', 54 | imageUrls: [ 55 | 'https://images.pexels.com/photos/744366/pexels-photo-744366.jpeg?auto=compress&cs=tinysrgb&w=1280' 56 | ], 57 | dateAdded: new Date().toISOString(), 58 | tags: ['micro', '3inch', 'digital'], 59 | specs: { 60 | weight: 180, 61 | size: '3 inch', 62 | motorKv: 4500, 63 | batteryConfig: '3S 450mAh', 64 | flightController: 'HGLRC Zeus F722 Mini', 65 | vtx: 'Caddx Vista' 66 | } 67 | } 68 | ]; 69 | 70 | // Initial custom tags 71 | const initialCustomTags = ['freestyle', '5inch', '3inch', 'micro', 'analog', 'digital']; 72 | 73 | // Load saved data from localStorage 74 | const loadSavedData = () => { 75 | try { 76 | const savedData = localStorage.getItem(STORAGE_KEY); 77 | if (savedData) { 78 | const parsed = JSON.parse(savedData); 79 | 80 | // Handle different data structures 81 | let items, customTags; 82 | if (Array.isArray(parsed)) { 83 | // If the data is directly an array 84 | items = parsed; 85 | customTags = initialCustomTags; 86 | } else if (parsed && typeof parsed === 'object') { 87 | // If the data is wrapped in an object 88 | if (Array.isArray(parsed.items)) { 89 | items = parsed.items; 90 | customTags = parsed.customTags || initialCustomTags; 91 | } else if (Array.isArray(parsed.galleryItems)) { 92 | items = parsed.galleryItems; 93 | customTags = parsed.customTags || initialCustomTags; 94 | } else if (Array.isArray(parsed.data)) { 95 | items = parsed.data; 96 | customTags = parsed.customTags || initialCustomTags; 97 | } else { 98 | console.warn('Unexpected gallery data structure:', parsed); 99 | return { items: sampleItems, customTags: initialCustomTags }; 100 | } 101 | } else { 102 | console.warn('Unexpected gallery data structure:', parsed); 103 | return { items: sampleItems, customTags: initialCustomTags }; 104 | } 105 | 106 | // Ensure dates are properly parsed 107 | const parsedItems = items.map((item: any) => ({ 108 | ...item, 109 | dateAdded: new Date(item.dateAdded).toISOString() 110 | })); 111 | 112 | console.log(`Loaded ${parsedItems.length} gallery items from localStorage`); 113 | return { items: parsedItems, customTags }; 114 | } 115 | } catch (error) { 116 | console.error('Error loading gallery data from localStorage:', error); 117 | } 118 | return { items: sampleItems, customTags: initialCustomTags }; 119 | }; 120 | 121 | const { items: savedItems, customTags: savedCustomTags } = loadSavedData(); 122 | 123 | export const useGalleryStore = create((set, get) => ({ 124 | items: savedItems, 125 | filteredItems: savedItems, 126 | customTags: savedCustomTags, 127 | filterOptions: { 128 | searchTerm: '', 129 | tags: [] 130 | }, 131 | 132 | // Actions 133 | addItem: (item) => { 134 | const newItem: GalleryItem = { 135 | ...item, 136 | id: uuidv4(), 137 | dateAdded: new Date().toISOString() 138 | }; 139 | 140 | // Add any new tags to customTags 141 | const newTags = item.tags.filter(tag => !get().customTags.includes(tag)); 142 | if (newTags.length > 0) { 143 | set(state => ({ 144 | customTags: [...state.customTags, ...newTags] 145 | })); 146 | } 147 | 148 | set((state) => ({ 149 | items: [...state.items, newItem] 150 | })); 151 | get().applyFilters(); 152 | 153 | // Save to localStorage 154 | const { items, customTags } = get(); 155 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, customTags })); 156 | }, 157 | 158 | updateItem: (id, itemData) => { 159 | // Add any new tags to customTags 160 | if (itemData.tags) { 161 | const newTags = itemData.tags.filter(tag => !get().customTags.includes(tag)); 162 | if (newTags.length > 0) { 163 | set(state => ({ 164 | customTags: [...state.customTags, ...newTags] 165 | })); 166 | } 167 | } 168 | 169 | set((state) => ({ 170 | items: state.items.map((item) => 171 | item.id === id 172 | ? { ...item, ...itemData } 173 | : item 174 | ) 175 | })); 176 | get().applyFilters(); 177 | 178 | // Save to localStorage 179 | const { items, customTags } = get(); 180 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, customTags })); 181 | }, 182 | 183 | deleteItem: (id) => { 184 | // Remove unused tags 185 | const remainingItems = get().items.filter(item => item.id !== id); 186 | const usedTags = new Set(remainingItems.flatMap(item => item.tags)); 187 | 188 | set((state) => ({ 189 | items: remainingItems, 190 | customTags: state.customTags.filter(tag => usedTags.has(tag)) 191 | })); 192 | get().applyFilters(); 193 | 194 | // Save to localStorage 195 | const { items, customTags } = get(); 196 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, customTags })); 197 | }, 198 | 199 | getItem: (id) => { 200 | return get().items.find((item) => item.id === id); 201 | }, 202 | 203 | addCustomTag: (tag) => { 204 | const normalizedTag = tag.toLowerCase().trim(); 205 | if (!get().customTags.includes(normalizedTag)) { 206 | set((state) => ({ 207 | customTags: [...state.customTags, normalizedTag] 208 | })); 209 | 210 | // Save to localStorage 211 | const { items, customTags } = get(); 212 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, customTags })); 213 | } 214 | }, 215 | 216 | removeCustomTag: (tag) => { 217 | // Only remove if no items are using this tag 218 | const isTagInUse = get().items.some(item => item.tags.includes(tag)); 219 | if (!isTagInUse) { 220 | set((state) => ({ 221 | customTags: state.customTags.filter(t => t !== tag), 222 | filterOptions: { 223 | ...state.filterOptions, 224 | tags: state.filterOptions.tags.filter(t => t !== tag) 225 | } 226 | })); 227 | get().applyFilters(); 228 | 229 | // Save to localStorage 230 | const { items, customTags } = get(); 231 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, customTags })); 232 | } 233 | }, 234 | 235 | setFilterOptions: (options) => { 236 | set((state) => ({ 237 | filterOptions: { ...state.filterOptions, ...options } 238 | })); 239 | get().applyFilters(); 240 | }, 241 | 242 | applyFilters: () => { 243 | const { items, filterOptions } = get(); 244 | 245 | let filtered = [...items]; 246 | 247 | // Filter by search term 248 | if (filterOptions.searchTerm) { 249 | const term = filterOptions.searchTerm.toLowerCase().trim(); 250 | filtered = filtered.filter((item) => 251 | item.title.toLowerCase().includes(term) || 252 | item.description.toLowerCase().includes(term) || 253 | item.tags.some(tag => tag.toLowerCase().includes(term)) || 254 | (item.specs?.size && item.specs.size.toLowerCase().includes(term)) || 255 | (item.specs?.flightController && item.specs.flightController.toLowerCase().includes(term)) 256 | ); 257 | } 258 | 259 | // Filter by tags (show items that have ANY of the selected tags) 260 | if (filterOptions.tags.length > 0) { 261 | filtered = filtered.filter((item) => 262 | filterOptions.tags.some(selectedTag => 263 | item.tags.some(itemTag => 264 | itemTag.toLowerCase() === selectedTag.toLowerCase() 265 | ) 266 | ) 267 | ); 268 | } 269 | 270 | // Sort by date (newest first) 271 | filtered.sort((a, b) => 272 | new Date(b.dateAdded).getTime() - new Date(a.dateAdded).getTime() 273 | ); 274 | 275 | set({ filteredItems: filtered }); 276 | } 277 | })); -------------------------------------------------------------------------------- /src/store/linkStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { Link } from '../models/types'; 4 | 5 | const STORAGE_KEY = 'quadparts_links_data'; 6 | 7 | interface LinkState { 8 | links: Link[]; 9 | filteredLinks: Link[]; 10 | customTags: string[]; 11 | filterOptions: { 12 | searchTerm: string; 13 | categories: ('website' | 'youtube' | 'blog' | 'store' | 'other')[]; 14 | tags: string[]; 15 | favoritesOnly: boolean; 16 | }; 17 | 18 | // Actions 19 | addLink: (link: Omit) => void; 20 | updateLink: (id: string, linkData: Partial) => void; 21 | deleteLink: (id: string) => void; 22 | toggleFavorite: (id: string) => void; 23 | updateLastVisited: (id: string) => void; 24 | 25 | addCustomTag: (tag: string) => void; 26 | removeCustomTag: (tag: string) => void; 27 | 28 | setFilterOptions: (options: Partial) => void; 29 | applyFilters: () => void; 30 | } 31 | 32 | // Sample data 33 | const sampleLinks: Link[] = [ 34 | { 35 | id: '1', 36 | title: 'Joshua Bardwell', 37 | url: 'https://www.youtube.com/@JoshuaBardwell', 38 | description: 'The best FPV drone tutorials and reviews', 39 | category: 'youtube', 40 | tags: ['tutorial', 'review', 'education'], 41 | dateAdded: new Date().toISOString(), 42 | isFavorite: true 43 | }, 44 | { 45 | id: '2', 46 | title: 'Oscar Liang', 47 | url: 'https://oscarliang.com', 48 | description: 'Comprehensive FPV drone guides and build logs', 49 | category: 'blog', 50 | tags: ['guide', 'tutorial', 'build'], 51 | dateAdded: new Date().toISOString(), 52 | isFavorite: true 53 | } 54 | ]; 55 | 56 | // Initial custom tags 57 | const initialCustomTags = ['tutorial', 'review', 'education', 'guide', 'build']; 58 | 59 | // Load saved data from localStorage 60 | const loadSavedData = () => { 61 | try { 62 | const savedData = localStorage.getItem(STORAGE_KEY); 63 | if (savedData) { 64 | const parsed = JSON.parse(savedData); 65 | 66 | // Handle different data structures 67 | let links, customTags; 68 | if (Array.isArray(parsed)) { 69 | // If the data is directly an array 70 | links = parsed; 71 | customTags = initialCustomTags; 72 | } else if (parsed && typeof parsed === 'object') { 73 | // If the data is wrapped in an object 74 | if (Array.isArray(parsed.links)) { 75 | links = parsed.links; 76 | customTags = parsed.customTags || initialCustomTags; 77 | } else if (Array.isArray(parsed.data)) { 78 | links = parsed.data; 79 | customTags = parsed.customTags || initialCustomTags; 80 | } else { 81 | console.warn('Unexpected links data structure:', parsed); 82 | return { links: sampleLinks, customTags: initialCustomTags }; 83 | } 84 | } else { 85 | console.warn('Unexpected links data structure:', parsed); 86 | return { links: sampleLinks, customTags: initialCustomTags }; 87 | } 88 | 89 | console.log(`Loaded ${links.length} links from localStorage`); 90 | return { links, customTags }; 91 | } 92 | } catch (error) { 93 | console.error('Error loading links data from localStorage:', error); 94 | } 95 | return { links: sampleLinks, customTags: initialCustomTags }; 96 | }; 97 | 98 | const { links: savedLinks, customTags: savedCustomTags } = loadSavedData(); 99 | 100 | export const useLinkStore = create((set, get) => ({ 101 | links: savedLinks, 102 | filteredLinks: savedLinks, 103 | customTags: savedCustomTags, 104 | filterOptions: { 105 | searchTerm: '', 106 | categories: ['website', 'youtube', 'blog', 'store', 'other'], 107 | tags: [], 108 | favoritesOnly: false 109 | }, 110 | 111 | // Actions 112 | addLink: (link) => { 113 | const newLink: Link = { 114 | ...link, 115 | id: uuidv4(), 116 | dateAdded: new Date().toISOString(), 117 | isFavorite: false 118 | }; 119 | 120 | // Add any new tags to customTags 121 | const newTags = link.tags.filter(tag => !get().customTags.includes(tag)); 122 | if (newTags.length > 0) { 123 | set(state => ({ 124 | customTags: [...state.customTags, ...newTags] 125 | })); 126 | } 127 | 128 | set((state) => ({ 129 | links: [...state.links, newLink] 130 | })); 131 | get().applyFilters(); 132 | 133 | // Save to localStorage 134 | const { links, customTags } = get(); 135 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ links, customTags })); 136 | }, 137 | 138 | updateLink: (id, linkData) => { 139 | // Add any new tags to customTags 140 | if (linkData.tags) { 141 | const newTags = linkData.tags.filter(tag => !get().customTags.includes(tag)); 142 | if (newTags.length > 0) { 143 | set(state => ({ 144 | customTags: [...state.customTags, ...newTags] 145 | })); 146 | } 147 | } 148 | 149 | set((state) => ({ 150 | links: state.links.map((link) => 151 | link.id === id 152 | ? { ...link, ...linkData } 153 | : link 154 | ) 155 | })); 156 | get().applyFilters(); 157 | 158 | // Save to localStorage 159 | const { links, customTags } = get(); 160 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ links, customTags })); 161 | }, 162 | 163 | deleteLink: (id) => { 164 | // Remove unused tags 165 | const remainingLinks = get().links.filter(link => link.id !== id); 166 | const usedTags = new Set(remainingLinks.flatMap(link => link.tags)); 167 | 168 | set((state) => ({ 169 | links: remainingLinks, 170 | customTags: state.customTags.filter(tag => usedTags.has(tag)) 171 | })); 172 | get().applyFilters(); 173 | 174 | // Save to localStorage 175 | const { links, customTags } = get(); 176 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ links, customTags })); 177 | }, 178 | 179 | toggleFavorite: (id) => { 180 | set((state) => ({ 181 | links: state.links.map((link) => 182 | link.id === id 183 | ? { ...link, isFavorite: !link.isFavorite } 184 | : link 185 | ) 186 | })); 187 | get().applyFilters(); 188 | 189 | // Save to localStorage 190 | const { links, customTags } = get(); 191 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ links, customTags })); 192 | }, 193 | 194 | updateLastVisited: (id) => { 195 | set((state) => ({ 196 | links: state.links.map((link) => 197 | link.id === id 198 | ? { ...link, lastVisited: new Date().toISOString() } 199 | : link 200 | ) 201 | })); 202 | get().applyFilters(); 203 | 204 | // Save to localStorage 205 | const { links, customTags } = get(); 206 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ links, customTags })); 207 | }, 208 | 209 | addCustomTag: (tag) => { 210 | const normalizedTag = tag.toLowerCase().trim(); 211 | if (!get().customTags.includes(normalizedTag)) { 212 | set((state) => ({ 213 | customTags: [...state.customTags, normalizedTag] 214 | })); 215 | 216 | // Save to localStorage 217 | const { links, customTags } = get(); 218 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ links, customTags })); 219 | } 220 | }, 221 | 222 | removeCustomTag: (tag) => { 223 | // Only remove if no links are using this tag 224 | const isTagInUse = get().links.some(link => link.tags.includes(tag)); 225 | if (!isTagInUse) { 226 | set((state) => ({ 227 | customTags: state.customTags.filter(t => t !== tag), 228 | filterOptions: { 229 | ...state.filterOptions, 230 | tags: state.filterOptions.tags.filter(t => t !== tag) 231 | } 232 | })); 233 | get().applyFilters(); 234 | 235 | // Save to localStorage 236 | const { links, customTags } = get(); 237 | localStorage.setItem(STORAGE_KEY, JSON.stringify({ links, customTags })); 238 | } 239 | }, 240 | 241 | setFilterOptions: (options) => { 242 | set((state) => ({ 243 | filterOptions: { ...state.filterOptions, ...options } 244 | })); 245 | get().applyFilters(); 246 | }, 247 | 248 | applyFilters: () => { 249 | const { links, filterOptions } = get(); 250 | 251 | let filtered = [...links]; 252 | 253 | // Filter by search term 254 | if (filterOptions.searchTerm) { 255 | const term = filterOptions.searchTerm.toLowerCase(); 256 | filtered = filtered.filter((link) => 257 | link.title.toLowerCase().includes(term) || 258 | link.description?.toLowerCase().includes(term) || 259 | link.url.toLowerCase().includes(term) || 260 | link.tags.some(tag => tag.toLowerCase().includes(term)) 261 | ); 262 | } 263 | 264 | // Filter by categories 265 | if (filterOptions.categories.length > 0) { 266 | filtered = filtered.filter((link) => 267 | filterOptions.categories.includes(link.category) 268 | ); 269 | } 270 | 271 | // Filter by tags 272 | if (filterOptions.tags.length > 0) { 273 | filtered = filtered.filter((link) => 274 | filterOptions.tags.some(tag => link.tags.includes(tag)) 275 | ); 276 | } 277 | 278 | // Filter favorites 279 | if (filterOptions.favoritesOnly) { 280 | filtered = filtered.filter((link) => link.isFavorite); 281 | } 282 | 283 | // Sort by date (newest first) 284 | filtered.sort((a, b) => 285 | new Date(b.dateAdded).getTime() - new Date(a.dateAdded).getTime() 286 | ); 287 | 288 | set({ filteredLinks: filtered }); 289 | } 290 | })); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QuadParts - updated 06-28-2025 2 | 3 | Drone / FPV inventory application where you can keep track of your parts, builds, notes and more 4 |
5 | 6 | ![Drone Parts Inventory](https://img.shields.io/badge/React-18.3.1-blue?style=for-the-badge&logo=react) 7 | ![TypeScript](https://img.shields.io/badge/TypeScript-5.3.3-blue?style=for-the-badge&logo=typescript) 8 | ![Tailwind CSS](https://img.shields.io/badge/Tailwind-3.4.1-38B2AC?style=for-the-badge&logo=tailwind-css) 9 | ![PWA](https://img.shields.io/badge/PWA-Ready-green?style=for-the-badge&logo=pwa) 10 | ### 📦 App Features 11 | - **Complete Parts Tracking**: Add, edit, and delete drone parts with detailed information 12 | - **Multi-Image Support**: Upload and manage multiple images per part 13 | - **Advanced Filtering**: Filter by category, status, condition, and search terms 14 | - **Stock Management**: Track quantities, in-use items, and low stock alerts 15 | - **Condition Tracking**: Monitor part conditions (new, good, fair, poor, broken, needs-repair) 16 | - **Price Tracking**: Monitor inventory value and individual part costs 17 | - **Grid & List Views**: Flexible viewing options for different preferences 18 | - **Plus much more... 19 | --- 20 | ![image](https://github.com/user-attachments/assets/73340e88-a524-4f4d-9b30-5d2abfee0017) 21 | 22 | 23 | 24 |
25 | Demo: https://fpv.builders/ 26 | 27 | 28 | --- 29 | Notable Updates: 30 | 31 | 1. Added the liquid glass effect as seen on Iphones. demo page can be see at https://fpv.builders/liquid-demo or on your local machine at http://localhost:5173/liquid-demo 32 | 2. Themes are now working and you have the option to to customize your own ( still working threw kinks on the "custom" theming) 33 | 3. Distiguish if inventory items is "In Use" or "In Stock". 34 | 4. Editing inventory item issues seen in previous versions is now fixed 35 | 5. Currency selection formatting fixed 36 | 6. Added A Flight Log Section so you can add relevant information about your flights rips 37 | 7. Other minor tweaks to the UI along with better local storage management. 38 | --- 39 | INSTALLING from Previous Versions: 40 |
41 | FOR THOSE WHO'S UPGRADING FROM THE RELEASE ON 6/10/2025, PLEASE EXPORT YOUR DATA BEFORE INSTALLING THIS VERSION. ONCE YOU INSTALL THIS VERSION, PLEASE GO TO THE "SETTINGS" PAGE AND IMPORT YOUR DATA. IF ANY OF YOU INVENTORY ITEMS OR ITEMS IN ANY CATEGORY IS NOT PRESENT, PLEASE GO TO THE "SETTINGS" AND AND CLICK "MIGRATE in the Data Management section. for example: 42 | --- 43 | ![image](https://github.com/user-attachments/assets/0bab302a-6785-447d-8ec1-a3846d9a4e66) 44 | --- 45 | **First time user INSTALL Instructions- For New Users of QuadParts** 46 |
47 | 1. Clone this repo. unzip the file. 48 | 2. Ensure that you have Node installed on your development machine ( windows, linux or other) 49 | Follow the tutorial here to install the latest version of node https://www.geeksforgeeks.org/how-to-download-and-install-node-js-and-npm/ 50 | 51 | After Node /NPM is installed on your system, open up your terminal or command prompt screen and run the two commands below to deploy: 52 | 53 | npm install #installs dependencies 54 | 55 | npm run dev #to deploy the code 56 |
57 | _above commands needs to be ran from from within the QuadParts folder. terminal-->cd /where/you/have/quadparts/unzipped_ 58 | **Note:** _If you want to be able to get to QuadParts from other devices on your network, run the command below_ 59 | npm run dev -- --host _(note: your data from the host wont be seen on the network client. export from the host and load on the client. currently working on fixing this issue for the next version. )_ 60 | You might have to run the npm audit fix if prompted to do so. Dont worry the app wont break by doing so 61 | 62 | On your local machine, pull up the browser and go to this address localhost:5173 63 | Note: All your personal exports can be modified on the official site fpv.builders if needed. just be sure to export your data once completed. 64 | --- 65 | Alternate installations methods-Advanced- https://github.com/hasmeni/QuadParts/blob/main/Alternate_install_methods.md 66 | --- 67 | Note: If the new version does not show up in your browser, you might have to clear your browser cache. 68 | Unfortunately by doing so you might loose your data if you installed the initial version released before 6/10/2025. 69 | For all others, who installed the version after 6/10/2025, simply export your data from the settings page, and import your complete date into this version 6/26/2025. 70 | 71 | --- 72 | 73 | Installing on Android: 74 | 75 | You can find the official APK here: https://github.com/hasmeni/QuadParts/releases/tag/apks 76 | 77 | --- 78 | Previous Notable Changes: 06 10 2025 79 | 1. Added the ability to export/import your data. This function also works with the demo site so if you happen to have a copy of your 80 | exported data on your phone or pc, you can simpy upload your file to the demo site and modify your data anywhere. you will find this feature 81 | in the settings section. 82 | 3. Added "Storage locations" section for inventory items so you can add where items are stored physically. Dropdown also available when adding 83 | new inventory items. 84 | 4. Fixed category issues in the inventory section. Now a dropdown will be seen with all available categories. 85 | 5. Fixed the inventory search function 86 | --- 87 | To DO: 88 | Making Progress with the Android Implementation of this app. Also the Docker version should be released next week.... hopefully :) . 89 | -Removing the animation in the background of the settings page. Users reported issues with firefox. 90 | --- 91 | 92 | Alternate Project Soon: Currently working on a client/ server variation of this app. 93 | 94 | Demo: https://fpv.builders/ 95 | --- 96 |
97 | FULL APP FEATURE SET 98 | ### 🏷️ Categories & Organization 99 | - **Hierarchical Categories**: Create main categories with subcategories 100 | - **Custom Colors & Icons**: Visual organization with color-coded categories 101 | - **Flexible Classification**: Organize parts by type, manufacturer, or custom criteria 102 | 103 | ### 📍 Storage Locations 104 | - **Location Management**: Track where parts are stored (shelves, drawers, boxes, etc.) 105 | - **Capacity Tracking**: Monitor storage capacity and current usage 106 | - **Smart Organization**: Keep inventory organized across multiple storage areas 107 | 108 | ### 🔧 Build Projects 109 | - **Build Notes**: Document drone builds with detailed notes and progress tracking 110 | - **Parts Integration**: Link parts to specific builds and track usage 111 | - **Progress Tracking**: Monitor build status (planning, in-progress, completed, archived) 112 | - **Cost Calculation**: Track total build costs and individual part expenses 113 | - **Image Documentation**: Add multiple images to document build progress 114 | - **Specifications**: Record technical specifications for each build 115 | 116 | ### 📊 Dashboard & Analytics 117 | - **Real-time Statistics**: View total parts, inventory value, and low stock alerts 118 | - **Quick Overview**: See recent parts, pending tasks, and important metrics 119 | - **Visual Indicators**: Color-coded status indicators and progress bars 120 | - **Export Capabilities**: Export data in various formats 121 | 122 | ### ✅ Task Management 123 | - **Todo Lists**: Create and manage tasks related to drone projects 124 | - **Priority Levels**: Set high, medium, or low priority for tasks 125 | - **Due Dates**: Track task deadlines and completion dates 126 | - **Part Integration**: Link tasks to specific parts or builds 127 | 128 | ### 📸 Gallery 129 | - **Image Management**: Organize drone photos and project images 130 | - **Tagging System**: Add tags for easy categorization and search 131 | - **Detailed Viewing**: Full-screen image viewing with metadata 132 | - **Specifications**: Store technical details with gallery items 133 | 134 | ### 🔗 Resource Links 135 | - **Bookmark Management**: Save useful websites, YouTube videos, and resources 136 | - **Categorization**: Organize links by type (website, YouTube, blog, store, other) 137 | - **Favorites System**: Mark important links for quick access 138 | - **Visit Tracking**: Monitor when links were last visited 139 | 140 | ### ✈️ Flight Log 141 | - **Flight Recording**: Log flight details including date, location, and duration 142 | - **Issue Tracking**: Document problems encountered during flights 143 | - **Drone Tracking**: Associate flights with specific drones 144 | - **Export Functionality**: Export flight logs to CSV format 145 | - **Search & Filter**: Find specific flights by various criteria 146 | 147 | ### ⚙️ Settings & Customization 148 | - **Theme Support**: Light and dark mode with customizable themes 149 | - **Data Management**: Import/export functionality for data backup 150 | - **User Preferences**: Customize application behavior and appearance 151 | - **System Configuration**: Adjust application settings and defaults 152 | 153 | ### 📱 Progressive Web App (PWA) 154 | - **Offline Support**: Access core features without internet connection 155 | - **App-like Experience**: Install as a native app on supported devices 156 | - **Push Notifications**: Get alerts for low stock and important updates 157 | - **Responsive Design**: Optimized for all screen sizes and devices 158 | 159 | ## 🛠️ Technology Stack 160 | 161 | - **Frontend**: React 18.3.1 with TypeScript 5.3.3 162 | - **Styling**: Tailwind CSS 3.4.1 with custom design system 163 | - **State Management**: Zustand for lightweight state management 164 | - **Routing**: React Router DOM 6.22.3 165 | - **Build Tool**: Vite 6.3.5 for fast development and building 166 | - **PWA**: Vite PWA plugin with service worker support 167 | - **Icons**: Lucide React for consistent iconography 168 | - **Linting**: ESLint with TypeScript and React rules 169 | 170 | -------------------------------------------------------------------------------- /src/pages/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { useInventoryStore } from '../store/inventoryStore'; 4 | import { useTodoStore } from '../store/todoStore'; 5 | import { useSettingsStore } from '../store/settingsStore'; 6 | import { AlertTriangle, Package, CheckCircle, ShoppingCart } from 'lucide-react'; 7 | import { formatCurrency } from '../utils/currency'; 8 | 9 | const Dashboard: React.FC = () => { 10 | const navigate = useNavigate(); 11 | const { parts, categories } = useInventoryStore(); 12 | const { todos } = useTodoStore(); 13 | const { settings } = useSettingsStore(); 14 | 15 | // Calculate statistics 16 | const totalParts = parts.length; 17 | const totalQuantity = parts.reduce((sum, part) => sum + part.quantity, 0); 18 | const lowStockThreshold = 3; 19 | const lowStockParts = parts.filter(part => part.quantity <= lowStockThreshold); 20 | const totalCategories = categories.length; 21 | const pendingTodos = todos.filter(todo => !todo.completed).length; 22 | 23 | // Calculate total inventory value 24 | const totalValue = parts.reduce((sum, part) => sum + (part.price * part.quantity), 0); 25 | 26 | return ( 27 |
28 |

Dashboard

29 | 30 | {/* Summary cards */} 31 |
32 |
33 |
34 |
35 |

Total Parts

36 |

{totalParts}

37 |
38 |
39 | 40 |
41 |
42 |
43 | Total Quantity: {totalQuantity} items 44 |
45 |
46 | 47 |
48 |
49 |
50 |

Inventory Value

51 |

{formatCurrency(totalValue)}

52 |
53 |
54 | 55 |
56 |
57 |
58 | Across {totalCategories} categories 59 |
60 |
61 | 62 |
63 |
64 |
65 |

Low Stock

66 |

{lowStockParts.length}

67 |
68 |
69 | 70 |
71 |
72 |
73 | Items below threshold ({lowStockThreshold}) 74 |
75 |
76 | 77 |
78 |
79 |
80 |

Tasks

81 |

{pendingTodos}

82 |
83 |
84 | 85 |
86 |
87 |
88 | Pending tasks to complete 89 |
90 |
91 |
92 | 93 |
94 | {/* Recent parts */} 95 |
96 |
97 |

Recent Parts

98 | 104 |
105 |
106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | {parts 117 | .sort((a, b) => new Date(b.dateAdded).getTime() - new Date(a.dateAdded).getTime()) 118 | .slice(0, 5) 119 | .map((part) => ( 120 | 121 | 136 | 137 | 142 | 143 | 144 | ))} 145 | 146 |
NameCategoryQuantityPrice
122 |
navigate(`/parts/${part.id}`)} 125 | > 126 | {part.imageUrls[0] && ( 127 | {part.name} 132 | )} 133 | {part.name} 134 |
135 |
{part.category} 138 | 139 | {part.quantity} 140 | 141 | {formatCurrency(part.price)}
147 |
148 |
149 | 150 | {/* Recent tasks */} 151 |
152 |
153 |

Pending Tasks

154 | 160 |
161 |
162 |
    163 | {todos 164 | .filter(todo => !todo.completed) 165 | .slice(0, 5) 166 | .map((todo) => ( 167 |
  • navigate('/todo')} 171 | > 172 |
    173 | 182 |
    183 |

    {todo.title}

    184 | {todo.description && ( 185 |

    {todo.description}

    186 | )} 187 | {todo.dateDue && ( 188 |

    189 | Due: {new Date(todo.dateDue).toLocaleDateString()} 190 |

    191 | )} 192 |
    193 |
    194 |
  • 195 | ))} 196 | 197 | {todos.filter(todo => !todo.completed).length === 0 && ( 198 |
  • 199 | No pending tasks. Good job! 200 |
  • 201 | )} 202 |
203 |
204 |
205 |
206 |
207 | ); 208 | }; 209 | 210 | export default Dashboard; -------------------------------------------------------------------------------- /src/pages/Gallery.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Plus, Search, Camera, Tag, X } from 'lucide-react'; 4 | import { useGalleryStore } from '../store/galleryStore'; 5 | import { GalleryItem } from '../models/types'; 6 | import { useToaster } from '../components/ui/Toaster'; 7 | 8 | const Gallery: React.FC = () => { 9 | const navigate = useNavigate(); 10 | const { filteredItems, filterOptions, setFilterOptions, customTags } = useGalleryStore(); 11 | const { addToast } = useToaster(); 12 | 13 | // Get all unique tags from current items 14 | const allTags = Array.from( 15 | new Set( 16 | filteredItems.flatMap(item => item.tags) 17 | ) 18 | ).sort(); 19 | 20 | // Get all available tags (including unused ones) 21 | const availableTags = Array.from(new Set([...allTags, ...customTags])).sort(); 22 | 23 | // Format date 24 | const formatDate = (dateString: string) => { 25 | return new Date(dateString).toLocaleDateString('en-US', { 26 | year: 'numeric', 27 | month: 'short', 28 | day: 'numeric' 29 | }); 30 | }; 31 | 32 | // Clear all filters 33 | const clearAllFilters = () => { 34 | setFilterOptions({ searchTerm: '', tags: [] }); 35 | addToast('success', 'All filters cleared'); 36 | }; 37 | 38 | // Remove specific tag filter 39 | const removeTagFilter = (tagToRemove: string) => { 40 | const newTags = filterOptions.tags.filter(t => t !== tagToRemove); 41 | setFilterOptions({ tags: newTags }); 42 | }; 43 | 44 | return ( 45 |
46 |
47 |
48 |

Gallery

49 |

Showcase your completed drone builds

50 |
51 | 52 | 59 |
60 | 61 | {/* Filters */} 62 |
63 |
64 |
65 | 66 | setFilterOptions({ searchTerm: e.target.value })} 71 | className="liquid-glass w-full pl-10 pr-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all duration-300" 72 | /> 73 |
74 | 75 | {(filterOptions.searchTerm || filterOptions.tags.length > 0) && ( 76 | 83 | )} 84 |
85 | 86 | {/* Active Filters Display */} 87 | {(filterOptions.searchTerm || filterOptions.tags.length > 0) && ( 88 |
89 |
Active Filters:
90 |
91 | {filterOptions.searchTerm && ( 92 | 93 | Search: "{filterOptions.searchTerm}" 94 | 100 | 101 | )} 102 | {filterOptions.tags.map(tag => ( 103 | 107 | 108 | {tag} 109 | 115 | 116 | ))} 117 |
118 |
119 | )} 120 | 121 | {/* Tag Filter Buttons */} 122 |
123 |
124 | 125 | Filter by Tags: 126 |
127 |
128 | {availableTags.map(tag => ( 129 | 151 | ))} 152 |
153 |
154 |
155 | 156 | {/* Gallery Grid */} 157 | {filteredItems.length === 0 ? ( 158 |
159 | 160 |

No builds in gallery

161 |

162 | Add your completed builds to showcase them in the gallery. 163 |

164 | 170 |
171 | ) : ( 172 |
173 | {filteredItems.map((item) => ( 174 |
navigate(`/gallery/${item.id}`)} 178 | > 179 |
180 | {item.imageUrls[0] ? ( 181 | {item.title} 186 | ) : ( 187 |
188 | 189 |
190 | )} 191 |
192 | 193 |
194 |

195 | {item.title} 196 |

197 | 198 |

199 | {item.description} 200 |

201 | 202 |
203 | {item.tags.map(tag => ( 204 | 208 | 209 | {tag} 210 | 211 | ))} 212 |
213 | 214 |
215 | {formatDate(item.dateAdded)} 216 |
217 |
218 |
219 | ))} 220 |
221 | )} 222 |
223 | ); 224 | }; 225 | 226 | export default Gallery; -------------------------------------------------------------------------------- /src/pages/BuildNotes.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Plus, Wrench, Clock, CheckCircle, Archive, Search } from 'lucide-react'; 4 | import { useBuildStore } from '../store/buildStore'; 5 | import { useSettingsStore } from '../store/settingsStore'; 6 | import { BuildNote } from '../models/types'; 7 | import { useToaster } from '../components/ui/Toaster'; 8 | import { formatCurrency } from '../utils/currency'; 9 | 10 | const BuildNotes: React.FC = () => { 11 | const navigate = useNavigate(); 12 | const { builds, filteredBuilds, filterOptions, setFilterOptions } = useBuildStore(); 13 | const { settings } = useSettingsStore(); 14 | const { addToast } = useToaster(); 15 | 16 | // Format date 17 | const formatDate = (dateString: string) => { 18 | return new Date(dateString).toLocaleDateString('en-US', { 19 | year: 'numeric', 20 | month: 'short', 21 | day: 'numeric' 22 | }); 23 | }; 24 | 25 | // Get status icon 26 | const getStatusIcon = (status: BuildNote['status']) => { 27 | switch (status) { 28 | case 'planning': 29 | return ; 30 | case 'in-progress': 31 | return ; 32 | case 'completed': 33 | return ; 34 | case 'archived': 35 | return ; 36 | } 37 | }; 38 | 39 | // Get status badge class 40 | const getStatusBadgeClass = (status: BuildNote['status']) => { 41 | switch (status) { 42 | case 'planning': 43 | return 'bg-blue-500/20 text-blue-400'; 44 | case 'in-progress': 45 | return 'bg-yellow-500/20 text-yellow-400'; 46 | case 'completed': 47 | return 'bg-green-500/20 text-green-400'; 48 | case 'archived': 49 | return 'bg-neutral-500/20 text-neutral-400'; 50 | } 51 | }; 52 | 53 | return ( 54 |
55 |
56 |
57 |

Build Notes

58 |

Document and track your drone builds

59 |
60 | 61 | 68 |
69 | 70 | {/* Filters */} 71 |
72 |
73 | 74 | setFilterOptions({ searchTerm: e.target.value })} 79 | className="liquid-glass w-full pl-10 pr-4 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all duration-300" 80 | /> 81 |
82 | 83 |
84 | 100 | 101 | 117 | 118 | 134 | 135 | 151 |
152 |
153 | 154 | {/* Builds Grid */} 155 |
156 | {filteredBuilds.map((build) => ( 157 |
navigate(`/builds/${build.id}`)} 161 | > 162 |
163 | {build.imageUrls[0] ? ( 164 | {build.title} 169 | ) : ( 170 |
171 | 172 |
173 | )} 174 |
175 | 176 |
177 |
178 |

179 | {build.title} 180 |

181 | 182 | {getStatusIcon(build.status)} 183 | {build.status.replace('-', ' ')} 184 | 185 |
186 | 187 |

188 | {build.description} 189 |

190 | 191 |
192 | 193 | {formatDate(build.dateCreated)} 194 | 195 | 196 | {formatCurrency(build.totalCost)} 197 | 198 |
199 | 200 | {build.specs && ( 201 |
202 |
203 | {build.specs.size && ( 204 |
205 | Size: 206 | {build.specs.size} 207 |
208 | )} 209 | {build.specs.weight && ( 210 |
211 | Weight: 212 | {build.specs.weight}g 213 |
214 | )} 215 |
216 |
217 | )} 218 |
219 |
220 | ))} 221 |
222 |
223 | ); 224 | }; 225 | 226 | export default BuildNotes; -------------------------------------------------------------------------------- /src/pages/LiquidGlassDemo.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import LiquidGlass from '../components/LiquidGlass'; 3 | import { Plus, Search, Settings, Bell, Package, CheckCircle, AlertTriangle } from 'lucide-react'; 4 | 5 | const LiquidGlassDemo: React.FC = () => { 6 | const [inputValue, setInputValue] = useState(''); 7 | const [isModalOpen, setIsModalOpen] = useState(false); 8 | 9 | return ( 10 |
11 |
12 |

Liquid Glass Effects Demo

13 |

14 | This page showcases all the liquid glass effects available in the QuadParts application. 15 | Hover over elements to see the liquid animations in action! 16 |

17 |
18 | 19 | {/* Basic Liquid Glass */} 20 |
21 |

Basic Liquid Glass

22 |
23 |
24 |

Default Effect

25 |

Basic liquid glass with hover animations

26 |
27 | 28 |
29 |

With Content

30 |

Content is properly layered above the effect

31 |
32 | 33 |
34 |

Interactive

35 |

Hover to see the liquid shine effect

36 |
37 |
38 |
39 | 40 | {/* Liquid Cards */} 41 |
42 |

Liquid Cards

43 |
44 |
45 |
46 |
47 |

Total Parts

48 |

156

49 |
50 |
51 | 52 |
53 |
54 |

Across 12 categories

55 |
56 | 57 |
58 |
59 |
60 |

Tasks

61 |

8

62 |
63 |
64 | 65 |
66 |
67 |

Pending completion

68 |
69 | 70 |
71 |
72 |
73 |

Alerts

74 |

3

75 |
76 |
77 | 78 |
79 |
80 |

Low stock items

81 |
82 | 83 |
84 |
85 |
86 |

Value

87 |

$2,450

88 |
89 |
90 | 91 |
92 |
93 |

Total inventory

94 |
95 |
96 |
97 | 98 | {/* Liquid Buttons */} 99 |
100 |

Liquid Buttons

101 |
102 | 103 | Primary Button 104 | 105 | 106 | 107 | Secondary Button 108 | 109 | 110 | 111 | Action Button 112 | 113 | 114 | 117 |
118 |
119 | 120 | {/* Liquid Inputs */} 121 |
122 |

Liquid Inputs

123 |
124 |
125 | 126 |
127 | setInputValue(e.target.value)} 132 | className="liquid-input w-full px-4 py-3 text-white rounded-lg pl-10 focus:outline-none" 133 | /> 134 | 135 |
136 |
137 | 138 |
139 | 140 | 145 |
146 |
147 |
148 | 149 | {/* Liquid Icons */} 150 |
151 |

Liquid Icons

152 |
153 | 156 | 157 | 160 | 161 | 164 | 165 | 168 |
169 |
170 | 171 | {/* Liquid Modal */} 172 |
173 |

Liquid Modal

174 | setIsModalOpen(true)}> 175 | Open Modal 176 | 177 | 178 | {isModalOpen && ( 179 |
180 |
181 |

Liquid Modal

182 |

183 | This modal uses the liquid glass effect with a smooth entrance animation. 184 |

185 |
186 | setIsModalOpen(false)}> 187 | Close 188 | 189 | 190 | Action 191 | 192 |
193 |
194 |
195 | )} 196 |
197 | 198 | {/* Theme Variations */} 199 |
200 |

Theme Variations

201 |
202 |
203 |

Default Theme

204 |

Works with all theme variations

205 |
206 | 207 |
208 |

Cyberpunk Theme

209 |

Yellow/gold accents

210 |
211 | 212 |
213 |

Matrix Theme

214 |

Green accents

215 |
216 |
217 |
218 | 219 | {/* Usage Instructions */} 220 |
221 |

Usage Instructions

222 |
223 |

How to Use Liquid Glass Effects

224 |
225 |

CSS Classes:

226 |
    227 |
  • liquid-glass - Basic liquid glass effect
  • 228 |
  • liquid-card - Card with hover lift effect
  • 229 |
  • liquid-button - Button with ripple effect
  • 230 |
  • liquid-input - Input with focus scaling
  • 231 |
  • liquid-modal - Modal with entrance animation
  • 232 |
  • liquid-sidebar - Sidebar with glass effect
  • 233 |
  • liquid-header - Header with glass effect
  • 234 |
235 | 236 |

React Component:

237 |
238 |
239 | {`import LiquidGlass from './components/LiquidGlass';
240 | 
241 | 
242 |   

Your Content

243 |

Content goes here

244 |
`} 245 |
246 |
247 |
248 |
249 |
250 |
251 | ); 252 | }; 253 | 254 | export default LiquidGlassDemo; -------------------------------------------------------------------------------- /dist/workbox-5ffe50d4.js: -------------------------------------------------------------------------------- 1 | define(["exports"],(function(t){"use strict";try{self["workbox:core:7.2.0"]&&_()}catch(t){}const e=(t,...e)=>{let s=t;return e.length>0&&(s+=` :: ${JSON.stringify(e)}`),s};class s extends Error{constructor(t,s){super(e(t,s)),this.name=t,this.details=s}}try{self["workbox:routing:7.2.0"]&&_()}catch(t){}const n=t=>t&&"object"==typeof t?t:{handle:t};class i{constructor(t,e,s="GET"){this.handler=n(e),this.match=t,this.method=s}setCatchHandler(t){this.catchHandler=n(t)}}class r extends i{constructor(t,e,s){super((({url:e})=>{const s=t.exec(e.href);if(s&&(e.origin===location.origin||0===s.index))return s.slice(1)}),e,s)}}class o{constructor(){this.t=new Map,this.i=new Map}get routes(){return this.t}addFetchListener(){self.addEventListener("fetch",(t=>{const{request:e}=t,s=this.handleRequest({request:e,event:t});s&&t.respondWith(s)}))}addCacheListener(){self.addEventListener("message",(t=>{if(t.data&&"CACHE_URLS"===t.data.type){const{payload:e}=t.data,s=Promise.all(e.urlsToCache.map((e=>{"string"==typeof e&&(e=[e]);const s=new Request(...e);return this.handleRequest({request:s,event:t})})));t.waitUntil(s),t.ports&&t.ports[0]&&s.then((()=>t.ports[0].postMessage(!0)))}}))}handleRequest({request:t,event:e}){const s=new URL(t.url,location.href);if(!s.protocol.startsWith("http"))return;const n=s.origin===location.origin,{params:i,route:r}=this.findMatchingRoute({event:e,request:t,sameOrigin:n,url:s});let o=r&&r.handler;const c=t.method;if(!o&&this.i.has(c)&&(o=this.i.get(c)),!o)return;let a;try{a=o.handle({url:s,request:t,event:e,params:i})}catch(t){a=Promise.reject(t)}const h=r&&r.catchHandler;return a instanceof Promise&&(this.o||h)&&(a=a.catch((async n=>{if(h)try{return await h.handle({url:s,request:t,event:e,params:i})}catch(t){t instanceof Error&&(n=t)}if(this.o)return this.o.handle({url:s,request:t,event:e});throw n}))),a}findMatchingRoute({url:t,sameOrigin:e,request:s,event:n}){const i=this.t.get(s.method)||[];for(const r of i){let i;const o=r.match({url:t,sameOrigin:e,request:s,event:n});if(o)return i=o,(Array.isArray(i)&&0===i.length||o.constructor===Object&&0===Object.keys(o).length||"boolean"==typeof o)&&(i=void 0),{route:r,params:i}}return{}}setDefaultHandler(t,e="GET"){this.i.set(e,n(t))}setCatchHandler(t){this.o=n(t)}registerRoute(t){this.t.has(t.method)||this.t.set(t.method,[]),this.t.get(t.method).push(t)}unregisterRoute(t){if(!this.t.has(t.method))throw new s("unregister-route-but-not-found-with-method",{method:t.method});const e=this.t.get(t.method).indexOf(t);if(!(e>-1))throw new s("unregister-route-route-not-registered");this.t.get(t.method).splice(e,1)}}let c;const a=()=>(c||(c=new o,c.addFetchListener(),c.addCacheListener()),c);function h(t,e,n){let o;if("string"==typeof t){const s=new URL(t,location.href);o=new i((({url:t})=>t.href===s.href),e,n)}else if(t instanceof RegExp)o=new r(t,e,n);else if("function"==typeof t)o=new i(t,e,n);else{if(!(t instanceof i))throw new s("unsupported-route-type",{moduleName:"workbox-routing",funcName:"registerRoute",paramName:"capture"});o=t}return a().registerRoute(o),o}const u={googleAnalytics:"googleAnalytics",precache:"precache-v2",prefix:"workbox",runtime:"runtime",suffix:"undefined"!=typeof registration?registration.scope:""},l=t=>[u.prefix,t,u.suffix].filter((t=>t&&t.length>0)).join("-"),f=t=>t||l(u.precache),w=t=>t||l(u.runtime);function d(t,e){const s=e();return t.waitUntil(s),s}try{self["workbox:precaching:7.2.0"]&&_()}catch(t){}function p(t){if(!t)throw new s("add-to-cache-list-unexpected-type",{entry:t});if("string"==typeof t){const e=new URL(t,location.href);return{cacheKey:e.href,url:e.href}}const{revision:e,url:n}=t;if(!n)throw new s("add-to-cache-list-unexpected-type",{entry:t});if(!e){const t=new URL(n,location.href);return{cacheKey:t.href,url:t.href}}const i=new URL(n,location.href),r=new URL(n,location.href);return i.searchParams.set("__WB_REVISION__",e),{cacheKey:i.href,url:r.href}}class y{constructor(){this.updatedURLs=[],this.notUpdatedURLs=[],this.handlerWillStart=async({request:t,state:e})=>{e&&(e.originalRequest=t)},this.cachedResponseWillBeUsed=async({event:t,state:e,cachedResponse:s})=>{if("install"===t.type&&e&&e.originalRequest&&e.originalRequest instanceof Request){const t=e.originalRequest.url;s?this.notUpdatedURLs.push(t):this.updatedURLs.push(t)}return s}}}class g{constructor({precacheController:t}){this.cacheKeyWillBeUsed=async({request:t,params:e})=>{const s=(null==e?void 0:e.cacheKey)||this.h.getCacheKeyForURL(t.url);return s?new Request(s,{headers:t.headers}):t},this.h=t}}let R;async function m(t,e){let n=null;if(t.url){n=new URL(t.url).origin}if(n!==self.location.origin)throw new s("cross-origin-copy-response",{origin:n});const i=t.clone(),r={headers:new Headers(i.headers),status:i.status,statusText:i.statusText},o=e?e(r):r,c=function(){if(void 0===R){const t=new Response("");if("body"in t)try{new Response(t.body),R=!0}catch(t){R=!1}R=!1}return R}()?i.body:await i.blob();return new Response(c,o)}function v(t,e){const s=new URL(t);for(const t of e)s.searchParams.delete(t);return s.href}class q{constructor(){this.promise=new Promise(((t,e)=>{this.resolve=t,this.reject=e}))}}const U=new Set;try{self["workbox:strategies:7.2.0"]&&_()}catch(t){}function L(t){return"string"==typeof t?new Request(t):t}class b{constructor(t,e){this.u={},Object.assign(this,e),this.event=e.event,this.l=t,this.p=new q,this.R=[],this.m=[...t.plugins],this.v=new Map;for(const t of this.m)this.v.set(t,{});this.event.waitUntil(this.p.promise)}async fetch(t){const{event:e}=this;let n=L(t);if("navigate"===n.mode&&e instanceof FetchEvent&&e.preloadResponse){const t=await e.preloadResponse;if(t)return t}const i=this.hasCallback("fetchDidFail")?n.clone():null;try{for(const t of this.iterateCallbacks("requestWillFetch"))n=await t({request:n.clone(),event:e})}catch(t){if(t instanceof Error)throw new s("plugin-error-request-will-fetch",{thrownErrorMessage:t.message})}const r=n.clone();try{let t;t=await fetch(n,"navigate"===n.mode?void 0:this.l.fetchOptions);for(const s of this.iterateCallbacks("fetchDidSucceed"))t=await s({event:e,request:r,response:t});return t}catch(t){throw i&&await this.runCallbacks("fetchDidFail",{error:t,event:e,originalRequest:i.clone(),request:r.clone()}),t}}async fetchAndCachePut(t){const e=await this.fetch(t),s=e.clone();return this.waitUntil(this.cachePut(t,s)),e}async cacheMatch(t){const e=L(t);let s;const{cacheName:n,matchOptions:i}=this.l,r=await this.getCacheKey(e,"read"),o=Object.assign(Object.assign({},i),{cacheName:n});s=await caches.match(r,o);for(const t of this.iterateCallbacks("cachedResponseWillBeUsed"))s=await t({cacheName:n,matchOptions:i,cachedResponse:s,request:r,event:this.event})||void 0;return s}async cachePut(t,e){const n=L(t);var i;await(i=0,new Promise((t=>setTimeout(t,i))));const r=await this.getCacheKey(n,"write");if(!e)throw new s("cache-put-with-no-response",{url:(o=r.url,new URL(String(o),location.href).href.replace(new RegExp(`^${location.origin}`),""))});var o;const c=await this.q(e);if(!c)return!1;const{cacheName:a,matchOptions:h}=this.l,u=await self.caches.open(a),l=this.hasCallback("cacheDidUpdate"),f=l?await async function(t,e,s,n){const i=v(e.url,s);if(e.url===i)return t.match(e,n);const r=Object.assign(Object.assign({},n),{ignoreSearch:!0}),o=await t.keys(e,r);for(const e of o)if(i===v(e.url,s))return t.match(e,n)}(u,r.clone(),["__WB_REVISION__"],h):null;try{await u.put(r,l?c.clone():c)}catch(t){if(t instanceof Error)throw"QuotaExceededError"===t.name&&await async function(){for(const t of U)await t()}(),t}for(const t of this.iterateCallbacks("cacheDidUpdate"))await t({cacheName:a,oldResponse:f,newResponse:c.clone(),request:r,event:this.event});return!0}async getCacheKey(t,e){const s=`${t.url} | ${e}`;if(!this.u[s]){let n=t;for(const t of this.iterateCallbacks("cacheKeyWillBeUsed"))n=L(await t({mode:e,request:n,event:this.event,params:this.params}));this.u[s]=n}return this.u[s]}hasCallback(t){for(const e of this.l.plugins)if(t in e)return!0;return!1}async runCallbacks(t,e){for(const s of this.iterateCallbacks(t))await s(e)}*iterateCallbacks(t){for(const e of this.l.plugins)if("function"==typeof e[t]){const s=this.v.get(e),n=n=>{const i=Object.assign(Object.assign({},n),{state:s});return e[t](i)};yield n}}waitUntil(t){return this.R.push(t),t}async doneWaiting(){let t;for(;t=this.R.shift();)await t}destroy(){this.p.resolve(null)}async q(t){let e=t,s=!1;for(const t of this.iterateCallbacks("cacheWillUpdate"))if(e=await t({request:this.request,response:e,event:this.event})||void 0,s=!0,!e)break;return s||e&&200!==e.status&&(e=void 0),e}}class C{constructor(t={}){this.cacheName=w(t.cacheName),this.plugins=t.plugins||[],this.fetchOptions=t.fetchOptions,this.matchOptions=t.matchOptions}handle(t){const[e]=this.handleAll(t);return e}handleAll(t){t instanceof FetchEvent&&(t={event:t,request:t.request});const e=t.event,s="string"==typeof t.request?new Request(t.request):t.request,n="params"in t?t.params:void 0,i=new b(this,{event:e,request:s,params:n}),r=this.U(i,s,e);return[r,this.L(r,i,s,e)]}async U(t,e,n){let i;await t.runCallbacks("handlerWillStart",{event:n,request:e});try{if(i=await this._(e,t),!i||"error"===i.type)throw new s("no-response",{url:e.url})}catch(s){if(s instanceof Error)for(const r of t.iterateCallbacks("handlerDidError"))if(i=await r({error:s,event:n,request:e}),i)break;if(!i)throw s}for(const s of t.iterateCallbacks("handlerWillRespond"))i=await s({event:n,request:e,response:i});return i}async L(t,e,s,n){let i,r;try{i=await t}catch(r){}try{await e.runCallbacks("handlerDidRespond",{event:n,request:s,response:i}),await e.doneWaiting()}catch(t){t instanceof Error&&(r=t)}if(await e.runCallbacks("handlerDidComplete",{event:n,request:s,response:i,error:r}),e.destroy(),r)throw r}}class E extends C{constructor(t={}){t.cacheName=f(t.cacheName),super(t),this.C=!1!==t.fallbackToNetwork,this.plugins.push(E.copyRedirectedCacheableResponsesPlugin)}async _(t,e){const s=await e.cacheMatch(t);return s||(e.event&&"install"===e.event.type?await this.O(t,e):await this.N(t,e))}async N(t,e){let n;const i=e.params||{};if(!this.C)throw new s("missing-precache-entry",{cacheName:this.cacheName,url:t.url});{const s=i.integrity,r=t.integrity,o=!r||r===s;n=await e.fetch(new Request(t,{integrity:"no-cors"!==t.mode?r||s:void 0})),s&&o&&"no-cors"!==t.mode&&(this.k(),await e.cachePut(t,n.clone()))}return n}async O(t,e){this.k();const n=await e.fetch(t);if(!await e.cachePut(t,n.clone()))throw new s("bad-precaching-response",{url:t.url,status:n.status});return n}k(){let t=null,e=0;for(const[s,n]of this.plugins.entries())n!==E.copyRedirectedCacheableResponsesPlugin&&(n===E.defaultPrecacheCacheabilityPlugin&&(t=s),n.cacheWillUpdate&&e++);0===e?this.plugins.push(E.defaultPrecacheCacheabilityPlugin):e>1&&null!==t&&this.plugins.splice(t,1)}}E.defaultPrecacheCacheabilityPlugin={cacheWillUpdate:async({response:t})=>!t||t.status>=400?null:t},E.copyRedirectedCacheableResponsesPlugin={cacheWillUpdate:async({response:t})=>t.redirected?await m(t):t};class O{constructor({cacheName:t,plugins:e=[],fallbackToNetwork:s=!0}={}){this.K=new Map,this.P=new Map,this.T=new Map,this.l=new E({cacheName:f(t),plugins:[...e,new g({precacheController:this})],fallbackToNetwork:s}),this.install=this.install.bind(this),this.activate=this.activate.bind(this)}get strategy(){return this.l}precache(t){this.addToCacheList(t),this.W||(self.addEventListener("install",this.install),self.addEventListener("activate",this.activate),this.W=!0)}addToCacheList(t){const e=[];for(const n of t){"string"==typeof n?e.push(n):n&&void 0===n.revision&&e.push(n.url);const{cacheKey:t,url:i}=p(n),r="string"!=typeof n&&n.revision?"reload":"default";if(this.K.has(i)&&this.K.get(i)!==t)throw new s("add-to-cache-list-conflicting-entries",{firstEntry:this.K.get(i),secondEntry:t});if("string"!=typeof n&&n.integrity){if(this.T.has(t)&&this.T.get(t)!==n.integrity)throw new s("add-to-cache-list-conflicting-integrities",{url:i});this.T.set(t,n.integrity)}if(this.K.set(i,t),this.P.set(i,r),e.length>0){const t=`Workbox is precaching URLs without revision info: ${e.join(", ")}\nThis is generally NOT safe. Learn more at https://bit.ly/wb-precache`;console.warn(t)}}}install(t){return d(t,(async()=>{const e=new y;this.strategy.plugins.push(e);for(const[e,s]of this.K){const n=this.T.get(s),i=this.P.get(e),r=new Request(e,{integrity:n,cache:i,credentials:"same-origin"});await Promise.all(this.strategy.handleAll({params:{cacheKey:s},request:r,event:t}))}const{updatedURLs:s,notUpdatedURLs:n}=e;return{updatedURLs:s,notUpdatedURLs:n}}))}activate(t){return d(t,(async()=>{const t=await self.caches.open(this.strategy.cacheName),e=await t.keys(),s=new Set(this.K.values()),n=[];for(const i of e)s.has(i.url)||(await t.delete(i),n.push(i.url));return{deletedURLs:n}}))}getURLsToCacheKeys(){return this.K}getCachedURLs(){return[...this.K.keys()]}getCacheKeyForURL(t){const e=new URL(t,location.href);return this.K.get(e.href)}getIntegrityForCacheKey(t){return this.T.get(t)}async matchPrecache(t){const e=t instanceof Request?t.url:t,s=this.getCacheKeyForURL(e);if(s){return(await self.caches.open(this.strategy.cacheName)).match(s)}}createHandlerBoundToURL(t){const e=this.getCacheKeyForURL(t);if(!e)throw new s("non-precached-url",{url:t});return s=>(s.request=new Request(t),s.params=Object.assign({cacheKey:e},s.params),this.strategy.handle(s))}}let x;const N=()=>(x||(x=new O),x);class k extends i{constructor(t,e){super((({request:s})=>{const n=t.getURLsToCacheKeys();for(const i of function*(t,{ignoreURLParametersMatching:e=[/^utm_/,/^fbclid$/],directoryIndex:s="index.html",cleanURLs:n=!0,urlManipulation:i}={}){const r=new URL(t,location.href);r.hash="",yield r.href;const o=function(t,e=[]){for(const s of[...t.searchParams.keys()])e.some((t=>t.test(s)))&&t.searchParams.delete(s);return t}(r,e);if(yield o.href,s&&o.pathname.endsWith("/")){const t=new URL(o.href);t.pathname+=s,yield t.href}if(n){const t=new URL(o.href);t.pathname+=".html",yield t.href}if(i){const t=i({url:r});for(const e of t)yield e.href}}(s.url,e)){const e=n.get(i);if(e){return{cacheKey:e,integrity:t.getIntegrityForCacheKey(e)}}}}),t.strategy)}}t.NavigationRoute=class extends i{constructor(t,{allowlist:e=[/./],denylist:s=[]}={}){super((t=>this.j(t)),t),this.M=e,this.S=s}j({url:t,request:e}){if(e&&"navigate"!==e.mode)return!1;const s=t.pathname+t.search;for(const t of this.S)if(t.test(s))return!1;return!!this.M.some((t=>t.test(s)))}},t.cleanupOutdatedCaches=function(){self.addEventListener("activate",(t=>{const e=f();t.waitUntil((async(t,e="-precache-")=>{const s=(await self.caches.keys()).filter((s=>s.includes(e)&&s.includes(self.registration.scope)&&s!==t));return await Promise.all(s.map((t=>self.caches.delete(t)))),s})(e).then((t=>{})))}))},t.clientsClaim=function(){self.addEventListener("activate",(()=>self.clients.claim()))},t.createHandlerBoundToURL=function(t){return N().createHandlerBoundToURL(t)},t.precacheAndRoute=function(t,e){!function(t){N().precache(t)}(t),function(t){const e=N();h(new k(e,t))}(e)},t.registerRoute=h})); 2 | -------------------------------------------------------------------------------- /src/store/inventoryStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { Part, Category, FilterOptions } from '../models/types'; 4 | 5 | // Load saved data from localStorage with enhanced error handling 6 | const loadFromStorage = (key: string, defaultValue: T): T => { 7 | try { 8 | const saved = localStorage.getItem(key); 9 | if (!saved) { 10 | console.log(`No saved data found for ${key}, using defaults`); 11 | return defaultValue; 12 | } 13 | 14 | const parsed = JSON.parse(saved); 15 | 16 | // Validate that the parsed data is an array (for parts, categories, etc.) 17 | if (Array.isArray(defaultValue) && !Array.isArray(parsed)) { 18 | console.error(`Invalid data format for ${key}: expected array, got ${typeof parsed}`); 19 | return defaultValue; 20 | } 21 | 22 | // Additional validation for parts array 23 | if (key === 'droneParts' && Array.isArray(parsed)) { 24 | const validParts = parsed.filter((part, index) => { 25 | if (!part || typeof part !== 'object') { 26 | console.warn(`Invalid part at index ${index}: not an object`); 27 | return false; 28 | } 29 | if (!part.id || !part.name) { 30 | console.warn(`Invalid part at index ${index}: missing required fields (id: ${!!part.id}, name: ${!!part.name})`); 31 | return false; 32 | } 33 | return true; 34 | }); 35 | 36 | if (validParts.length !== parsed.length) { 37 | console.warn(`Filtered out ${parsed.length - validParts.length} invalid parts from ${key}`); 38 | return validParts as T; 39 | } 40 | } 41 | 42 | // Additional validation for categories array 43 | if (key === 'droneCategories' && Array.isArray(parsed)) { 44 | const validCategories = parsed.filter((category, index) => { 45 | if (!category || typeof category !== 'object') { 46 | console.warn(`Invalid category at index ${index}: not an object`); 47 | return false; 48 | } 49 | if (!category.id || !category.name) { 50 | console.warn(`Invalid category at index ${index}: missing required fields (id: ${!!category.id}, name: ${!!category.name})`); 51 | return false; 52 | } 53 | return true; 54 | }); 55 | 56 | if (validCategories.length !== parsed.length) { 57 | console.warn(`Filtered out ${parsed.length - validCategories.length} invalid categories from ${key}`); 58 | return validCategories as T; 59 | } 60 | } 61 | 62 | console.log(`Successfully loaded ${key}:`, Array.isArray(parsed) ? `${parsed.length} items` : 'data'); 63 | return parsed; 64 | } catch (error) { 65 | console.error(`Error loading ${key} from localStorage:`, error); 66 | console.warn(`Using default data for ${key}`); 67 | return defaultValue; 68 | } 69 | }; 70 | 71 | // Save data to localStorage 72 | const saveToStorage = (key: string, value: any) => { 73 | try { 74 | localStorage.setItem(key, JSON.stringify(value)); 75 | } catch (error) { 76 | console.error(`Error saving ${key} to localStorage:`, error); 77 | } 78 | }; 79 | 80 | // Initial sample data 81 | const sampleCategories: Category[] = [ 82 | { 83 | id: '1', 84 | name: 'Motors', 85 | description: 'Propulsion systems for drones', 86 | color: '#3B82F6', 87 | subcategories: [ 88 | { id: '1-1', name: 'Brushless', description: 'Standard brushless motors', parentId: '1' }, 89 | { id: '1-2', name: 'Brushed', description: 'Standard brushed motors', parentId: '1' } 90 | ], 91 | dateAdded: new Date().toISOString() 92 | }, 93 | { 94 | id: '2', 95 | name: 'Frames', 96 | description: 'Structural components', 97 | color: '#10B981', 98 | subcategories: [ 99 | { id: '2-1', name: 'Carbon Fiber', description: 'Lightweight and strong', parentId: '2' }, 100 | { id: '2-2', name: 'Plastic', description: 'Affordable and flexible', parentId: '2' } 101 | ], 102 | dateAdded: new Date().toISOString() 103 | }, 104 | { 105 | id: '3', 106 | name: 'Electronics', 107 | description: 'Controllers and circuit boards', 108 | color: '#F59E0B', 109 | subcategories: [ 110 | { id: '3-1', name: 'Flight Controllers', description: 'Main drone CPU', parentId: '3' }, 111 | { id: '3-2', name: 'ESCs', description: 'Electronic Speed Controllers', parentId: '3' } 112 | ], 113 | dateAdded: new Date().toISOString() 114 | } 115 | ]; 116 | 117 | const sampleParts: Part[] = [ 118 | { 119 | id: '1', 120 | name: 'Emax ECO II 2306', 121 | category: 'Motors', 122 | subcategory: 'Brushless', 123 | quantity: 8, 124 | price: 24.99, 125 | location: 'Shelf A2', 126 | description: 'High-performance 2306 size brushless motor for 5-inch props', 127 | imageUrls: ['https://images.unsplash.com/photo-1579829215132-9b20155a2c7c?q=80&w=300'], 128 | manufacturer: 'Emax', 129 | modelNumber: 'ECO II 2306-2400KV', 130 | dateAdded: new Date().toISOString(), 131 | notes: 'Good durability, standard for 5-inch builds', 132 | inUse: 4, 133 | status: 'in-stock', 134 | condition: 'new' 135 | }, 136 | { 137 | id: '2', 138 | name: 'TBS Source One v5', 139 | category: 'Frames', 140 | subcategory: 'Carbon Fiber', 141 | quantity: 3, 142 | price: 39.99, 143 | location: 'Drawer B1', 144 | description: '5-inch freestyle frame, very durable with multiple mounting options', 145 | imageUrls: ['https://images.unsplash.com/photo-1468078809804-4c7b3e60a478?q=80&w=300'], 146 | manufacturer: 'Team BlackSheep', 147 | modelNumber: 'Source One v5', 148 | dateAdded: new Date().toISOString(), 149 | notes: 'Favorite frame for freestyle builds', 150 | inUse: 1, 151 | status: 'in-stock', 152 | condition: 'good' 153 | }, 154 | { 155 | id: '3', 156 | name: 'Matek F405-CTR', 157 | category: 'Electronics', 158 | subcategory: 'Flight Controllers', 159 | quantity: 4, 160 | price: 42.99, 161 | location: 'Box C3', 162 | description: 'F4 flight controller with integrated PDB and OSD', 163 | imageUrls: ['https://images.unsplash.com/photo-1574158622682-e40e69881006?q=80&w=300'], 164 | manufacturer: 'Matek', 165 | modelNumber: 'F405-CTR', 166 | dateAdded: new Date().toISOString(), 167 | notes: 'Good for compact builds', 168 | inUse: 2, 169 | status: 'in-use', 170 | condition: 'fair' 171 | } 172 | ]; 173 | 174 | // Load initial data from localStorage or use sample data 175 | const initialParts = loadFromStorage('droneParts', sampleParts); 176 | const initialCategories = loadFromStorage('droneCategories', sampleCategories); 177 | 178 | interface InventoryState { 179 | parts: Part[]; 180 | categories: Category[]; 181 | filteredParts: Part[]; 182 | filterOptions: FilterOptions; 183 | 184 | // Actions 185 | addPart: (part: Omit) => void; 186 | updatePart: (id: string, partData: Partial) => void; 187 | deletePart: (id: string) => void; 188 | getPart: (id: string) => Part | undefined; 189 | 190 | addCategory: (category: Omit) => void; 191 | updateCategory: (id: string, categoryData: Partial) => void; 192 | deleteCategory: (id: string) => void; 193 | getCategory: (id: string) => Category | undefined; 194 | 195 | addSubcategory: (categoryId: string, name: string, description?: string) => void; 196 | updateSubcategory: (categoryId: string, subcategoryId: string, name: string, description?: string) => void; 197 | deleteSubcategory: (categoryId: string, subcategoryId: string) => void; 198 | 199 | setFilterOptions: (options: Partial) => void; 200 | applyFilters: () => void; 201 | } 202 | 203 | export const useInventoryStore = create((set, get) => ({ 204 | parts: initialParts, 205 | categories: initialCategories, 206 | filteredParts: initialParts, 207 | filterOptions: { 208 | categories: [], 209 | searchTerm: '', 210 | inStock: true, 211 | sortBy: 'name', 212 | sortDirection: 'asc', 213 | status: ['in-stock', 'in-use'], 214 | conditions: ['new', 'good', 'fair', 'poor', 'broken', 'needs-repair'] 215 | }, 216 | 217 | // Part actions 218 | addPart: (part) => { 219 | const newPart: Part = { 220 | ...part, 221 | id: uuidv4(), 222 | dateAdded: new Date().toISOString() 223 | }; 224 | set((state) => { 225 | const newParts = [...state.parts, newPart]; 226 | saveToStorage('droneParts', newParts); 227 | return { 228 | parts: newParts 229 | }; 230 | }); 231 | get().applyFilters(); 232 | }, 233 | 234 | updatePart: (id, partData) => { 235 | set((state) => { 236 | const newParts = state.parts.map((part) => 237 | part.id === id 238 | ? { ...part, ...partData, lastModified: new Date().toISOString() } 239 | : part 240 | ); 241 | saveToStorage('droneParts', newParts); 242 | return { 243 | parts: newParts 244 | }; 245 | }); 246 | get().applyFilters(); 247 | }, 248 | 249 | deletePart: (id) => { 250 | set((state) => { 251 | const newParts = state.parts.filter((part) => part.id !== id); 252 | saveToStorage('droneParts', newParts); 253 | return { 254 | parts: newParts 255 | }; 256 | }); 257 | get().applyFilters(); 258 | }, 259 | 260 | getPart: (id) => { 261 | return get().parts.find((part) => part.id === id); 262 | }, 263 | 264 | // Category actions 265 | addCategory: (category) => { 266 | const newCategory: Category = { 267 | ...category, 268 | id: uuidv4(), 269 | subcategories: [], 270 | dateAdded: new Date().toISOString() 271 | }; 272 | set((state) => { 273 | const newCategories = [...state.categories, newCategory]; 274 | saveToStorage('droneCategories', newCategories); 275 | return { categories: newCategories }; 276 | }); 277 | }, 278 | 279 | updateCategory: (id, categoryData) => { 280 | set((state) => { 281 | const newCategories = state.categories.map((category) => 282 | category.id === id 283 | ? { ...category, ...categoryData, lastModified: new Date().toISOString() } 284 | : category 285 | ); 286 | saveToStorage('droneCategories', newCategories); 287 | return { categories: newCategories }; 288 | }); 289 | }, 290 | 291 | deleteCategory: (id) => { 292 | set((state) => { 293 | const newCategories = state.categories.filter((category) => category.id !== id); 294 | const newParts = state.parts.map(part => 295 | part.category === state.categories.find(c => c.id === id)?.name 296 | ? { ...part, category: 'Uncategorized' } 297 | : part 298 | ); 299 | saveToStorage('droneCategories', newCategories); 300 | saveToStorage('droneParts', newParts); 301 | return { 302 | categories: newCategories, 303 | parts: newParts 304 | }; 305 | }); 306 | }, 307 | 308 | getCategory: (id) => { 309 | return get().categories.find((category) => category.id === id); 310 | }, 311 | 312 | // Subcategory actions 313 | addSubcategory: (categoryId, name, description) => { 314 | const subcategoryId = uuidv4(); 315 | set((state) => { 316 | const newCategories = state.categories.map((category) => 317 | category.id === categoryId 318 | ? { 319 | ...category, 320 | subcategories: [ 321 | ...category.subcategories, 322 | { id: subcategoryId, name, description, parentId: categoryId } 323 | ], 324 | lastModified: new Date().toISOString() 325 | } 326 | : category 327 | ); 328 | saveToStorage('droneCategories', newCategories); 329 | return { categories: newCategories }; 330 | }); 331 | }, 332 | 333 | updateSubcategory: (categoryId, subcategoryId, name, description) => { 334 | set((state) => { 335 | const newCategories = state.categories.map((category) => 336 | category.id === categoryId 337 | ? { 338 | ...category, 339 | subcategories: category.subcategories.map(sub => 340 | sub.id === subcategoryId 341 | ? { ...sub, name, description } 342 | : sub 343 | ), 344 | lastModified: new Date().toISOString() 345 | } 346 | : category 347 | ); 348 | saveToStorage('droneCategories', newCategories); 349 | return { categories: newCategories }; 350 | }); 351 | }, 352 | 353 | deleteSubcategory: (categoryId, subcategoryId) => { 354 | set((state) => { 355 | const newCategories = state.categories.map((category) => 356 | category.id === categoryId 357 | ? { 358 | ...category, 359 | subcategories: category.subcategories.filter(sub => sub.id !== subcategoryId), 360 | lastModified: new Date().toISOString() 361 | } 362 | : category 363 | ); 364 | const newParts = state.parts.map(part => { 365 | const categoryName = state.categories.find(c => c.id === categoryId)?.name; 366 | const subcategoryName = state.categories 367 | .find(c => c.id === categoryId)?.subcategories 368 | .find(s => s.id === subcategoryId)?.name; 369 | 370 | if (part.category === categoryName && part.subcategory === subcategoryName) { 371 | return { ...part, subcategory: undefined }; 372 | } 373 | return part; 374 | }); 375 | saveToStorage('droneCategories', newCategories); 376 | saveToStorage('droneParts', newParts); 377 | return { 378 | categories: newCategories, 379 | parts: newParts 380 | }; 381 | }); 382 | }, 383 | 384 | // Search and filtering 385 | setFilterOptions: (options) => { 386 | set((state) => ({ 387 | filterOptions: { ...state.filterOptions, ...options }, 388 | })); 389 | get().applyFilters(); 390 | }, 391 | 392 | applyFilters: () => { 393 | const { parts, filterOptions } = get(); 394 | 395 | let filtered = [...parts]; 396 | 397 | // Apply search term filter 398 | if (filterOptions.searchTerm) { 399 | const term = filterOptions.searchTerm.toLowerCase(); 400 | filtered = filtered.filter((part) => 401 | part.name.toLowerCase().includes(term) || 402 | part.description.toLowerCase().includes(term) || 403 | part.manufacturer?.toLowerCase().includes(term) || 404 | part.modelNumber?.toLowerCase().includes(term) || 405 | part.notes?.toLowerCase().includes(term) 406 | ); 407 | } 408 | 409 | // Apply category filter 410 | if (filterOptions.categories.length > 0) { 411 | filtered = filtered.filter((part) => 412 | filterOptions.categories.includes(part.category) 413 | ); 414 | } 415 | 416 | // Apply status filter 417 | if (filterOptions.status && filterOptions.status.length > 0) { 418 | filtered = filtered.filter((part) => 419 | filterOptions.status.includes(part.status) 420 | ); 421 | } 422 | 423 | // Apply condition filter 424 | if (filterOptions.conditions && filterOptions.conditions.length > 0) { 425 | filtered = filtered.filter((part) => 426 | filterOptions.conditions.includes(part.condition) 427 | ); 428 | } 429 | 430 | // Apply in-stock filter 431 | if (filterOptions.inStock) { 432 | filtered = filtered.filter((part) => part.quantity > 0); 433 | } 434 | 435 | // Apply sorting 436 | filtered.sort((a, b) => { 437 | let valueA: any; 438 | let valueB: any; 439 | 440 | switch (filterOptions.sortBy) { 441 | case 'name': 442 | valueA = a.name.toLowerCase(); 443 | valueB = b.name.toLowerCase(); 444 | break; 445 | case 'category': 446 | valueA = a.category.toLowerCase(); 447 | valueB = b.category.toLowerCase(); 448 | break; 449 | case 'quantity': 450 | valueA = a.quantity; 451 | valueB = b.quantity; 452 | break; 453 | case 'dateAdded': 454 | valueA = new Date(a.dateAdded).getTime(); 455 | valueB = new Date(b.dateAdded).getTime(); 456 | break; 457 | default: 458 | valueA = a.name.toLowerCase(); 459 | valueB = b.name.toLowerCase(); 460 | } 461 | 462 | if (filterOptions.sortDirection === 'asc') { 463 | return valueA > valueB ? 1 : -1; 464 | } else { 465 | return valueA < valueB ? 1 : -1; 466 | } 467 | }); 468 | 469 | set({ filteredParts: filtered }); 470 | } 471 | })); -------------------------------------------------------------------------------- /src/pages/FlightLog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Plus, Trash2, Edit2, Download, ChevronUp, ChevronDown, Search } from 'lucide-react'; 3 | import { useFlightLogStore, FlightLogEntry } from '../store/flightLogStore'; 4 | 5 | const FlightLog: React.FC = () => { 6 | const { flightLogs, addFlightLog, updateFlightLog, deleteFlightLog, setFlightLogs } = useFlightLogStore(); 7 | const [isFormOpen, setIsFormOpen] = useState(false); 8 | const [currentLog, setCurrentLog] = useState({ 9 | id: '', 10 | date: '', 11 | drone: '', 12 | location: '', 13 | duration: '', 14 | notes: '', 15 | issues: '' 16 | }); 17 | const [sortConfig, setSortConfig] = useState<{ key: keyof FlightLogEntry; direction: 'asc' | 'desc' }>({ 18 | key: 'date', 19 | direction: 'desc' 20 | }); 21 | const [filters, setFilters] = useState({ 22 | drone: '', 23 | location: '', 24 | search: '' 25 | }); 26 | 27 | // Migration function to handle old localStorage data 28 | useEffect(() => { 29 | const migrateOldData = () => { 30 | const oldStorageKey = 'quadparts_flight_logs'; 31 | const oldData = localStorage.getItem(oldStorageKey); 32 | 33 | if (oldData && flightLogs.length === 0) { 34 | try { 35 | const parsedData = JSON.parse(oldData); 36 | if (Array.isArray(parsedData)) { 37 | console.log('Migrating old flight logs data:', parsedData.length, 'entries'); 38 | setFlightLogs(parsedData); 39 | localStorage.removeItem(oldStorageKey); 40 | console.log('Migration completed, old data removed'); 41 | } 42 | } catch (error) { 43 | console.error('Error migrating flight logs data:', error); 44 | } 45 | } 46 | }; 47 | 48 | migrateOldData(); 49 | }, [flightLogs.length, setFlightLogs]); 50 | 51 | const handleSubmit = (e: React.FormEvent) => { 52 | e.preventDefault(); 53 | if (currentLog.id) { 54 | updateFlightLog(currentLog.id, currentLog); 55 | } else { 56 | const { id, ...logWithoutId } = currentLog; 57 | addFlightLog(logWithoutId); 58 | } 59 | setIsFormOpen(false); 60 | setCurrentLog({ 61 | id: '', 62 | date: '', 63 | drone: '', 64 | location: '', 65 | duration: '', 66 | notes: '', 67 | issues: '' 68 | }); 69 | }; 70 | 71 | const handleDelete = (id: string) => { 72 | deleteFlightLog(id); 73 | }; 74 | 75 | const handleEdit = (log: FlightLogEntry) => { 76 | setCurrentLog(log); 77 | setIsFormOpen(true); 78 | }; 79 | 80 | const handleSort = (key: keyof FlightLogEntry) => { 81 | setSortConfig(current => ({ 82 | key, 83 | direction: current.key === key && current.direction === 'asc' ? 'desc' : 'asc' 84 | })); 85 | }; 86 | 87 | const handleFilterChange = (key: keyof typeof filters, value: string) => { 88 | setFilters(prev => ({ ...prev, [key]: value })); 89 | }; 90 | 91 | const exportToCSV = () => { 92 | const headers = ['Date', 'Drone', 'Location', 'Duration', 'Notes', 'Issues']; 93 | const csvContent = [ 94 | headers.join(','), 95 | ...filteredAndSortedLogs.map(log => [ 96 | log.date, 97 | log.drone, 98 | log.location, 99 | log.duration, 100 | `"${log.notes.replace(/"/g, '""')}"`, 101 | `"${log.issues.replace(/"/g, '""')}"` 102 | ].join(',')) 103 | ].join('\n'); 104 | 105 | const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); 106 | const link = document.createElement('a'); 107 | const url = URL.createObjectURL(blob); 108 | link.setAttribute('href', url); 109 | link.setAttribute('download', `flight_logs_${new Date().toISOString().split('T')[0]}.csv`); 110 | document.body.appendChild(link); 111 | link.click(); 112 | document.body.removeChild(link); 113 | }; 114 | 115 | const filteredAndSortedLogs = React.useMemo(() => { 116 | let filtered = [...flightLogs]; 117 | 118 | // Apply filters 119 | if (filters.drone) { 120 | filtered = filtered.filter(log => 121 | log.drone.toLowerCase().includes(filters.drone.toLowerCase()) 122 | ); 123 | } 124 | if (filters.location) { 125 | filtered = filtered.filter(log => 126 | log.location.toLowerCase().includes(filters.location.toLowerCase()) 127 | ); 128 | } 129 | if (filters.search) { 130 | const searchLower = filters.search.toLowerCase(); 131 | filtered = filtered.filter(log => 132 | Object.values(log).some(value => 133 | value.toLowerCase().includes(searchLower) 134 | ) 135 | ); 136 | } 137 | 138 | // Apply sorting 139 | return filtered.sort((a, b) => { 140 | if (a[sortConfig.key] < b[sortConfig.key]) { 141 | return sortConfig.direction === 'asc' ? -1 : 1; 142 | } 143 | if (a[sortConfig.key] > b[sortConfig.key]) { 144 | return sortConfig.direction === 'asc' ? 1 : -1; 145 | } 146 | return 0; 147 | }); 148 | }, [flightLogs, sortConfig, filters]); 149 | 150 | // Get unique drones and locations for filter dropdowns 151 | const uniqueDrones = React.useMemo(() => 152 | Array.from(new Set(flightLogs.map(log => log.drone))).sort(), 153 | [flightLogs] 154 | ); 155 | const uniqueLocations = React.useMemo(() => 156 | Array.from(new Set(flightLogs.map(log => log.location))).sort(), 157 | [flightLogs] 158 | ); 159 | 160 | return ( 161 |
162 |
163 |

Flight Log

164 |
165 | 172 | 179 |
180 |
181 | 182 | {/* Filters */} 183 |
184 |
185 | 186 | handleFilterChange('search', e.target.value)} 191 | className="liquid-glass w-full pl-10 pr-4 py-2 bg-neutral-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all duration-300" 192 | /> 193 |
194 | 204 | 214 |
215 | 216 | {isFormOpen && ( 217 |
218 |
219 |

220 | {currentLog.id ? 'Edit Flight Log' : 'Add Flight Log'} 221 |

222 |
223 |
224 |
225 | 226 | setCurrentLog({ ...currentLog, date: e.target.value })} 230 | className="liquid-glass w-full px-3 py-2 bg-neutral-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all duration-300" 231 | required 232 | /> 233 |
234 |
235 | 236 | setCurrentLog({ ...currentLog, drone: e.target.value })} 240 | className="liquid-glass w-full px-3 py-2 bg-neutral-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all duration-300" 241 | required 242 | /> 243 |
244 |
245 | 246 | setCurrentLog({ ...currentLog, location: e.target.value })} 250 | className="liquid-glass w-full px-3 py-2 bg-neutral-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all duration-300" 251 | required 252 | /> 253 |
254 |
255 | 256 | setCurrentLog({ ...currentLog, duration: e.target.value })} 260 | placeholder="e.g., 15 minutes" 261 | className="liquid-glass w-full px-3 py-2 bg-neutral-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all duration-300" 262 | required 263 | /> 264 |
265 |
266 |
267 | 268 |