├── 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 |
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 |
39 |
40 | {children}
41 |
42 |
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 |
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 |
removeToast(toast.id)}
110 | className="ml-4 text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-white"
111 | >
112 |
113 |
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 |
55 | {isMobileMenuOpen ? : }
56 |
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 | {
54 | // Clear potentially corrupted data
55 | localStorage.clear();
56 | window.location.reload();
57 | }}
58 | className="w-full px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
59 | >
60 | Clear Data & Reload
61 |
62 | window.location.reload()}
64 | className="w-full px-4 py-2 bg-neutral-700 hover:bg-neutral-600 text-white rounded-lg transition-colors"
65 | >
66 | Reload Page
67 |
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 | 
7 | 
8 | 
9 | 
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 | 
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 | 
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 |
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 |
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 | navigate('/inventory')}
100 | className="text-primary-400 hover:text-primary-300 text-sm"
101 | >
102 | View All
103 |
104 |
105 |
106 |
107 |
108 |
109 | Name
110 | Category
111 | Quantity
112 | Price
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 |
122 | navigate(`/parts/${part.id}`)}
125 | >
126 | {part.imageUrls[0] && (
127 |
132 | )}
133 |
{part.name}
134 |
135 |
136 | {part.category}
137 |
138 |
139 | {part.quantity}
140 |
141 |
142 | {formatCurrency(part.price)}
143 |
144 | ))}
145 |
146 |
147 |
148 |
149 |
150 | {/* Recent tasks */}
151 |
152 |
153 |
Pending Tasks
154 | navigate('/todo')}
156 | className="text-primary-400 hover:text-primary-300 text-sm"
157 | >
158 | View All
159 |
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 |
navigate('/gallery/new')}
54 | className="liquid-glass flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-all duration-300"
55 | >
56 |
57 | Add Build
58 |
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 |
80 |
81 | Clear All
82 |
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 | setFilterOptions({ searchTerm: '' })}
96 | className="text-primary-400 hover:text-primary-300 transition-colors"
97 | >
98 |
99 |
100 |
101 | )}
102 | {filterOptions.tags.map(tag => (
103 |
107 |
108 | {tag}
109 | removeTagFilter(tag)}
111 | className="text-secondary-400 hover:text-secondary-300 transition-colors"
112 | >
113 |
114 |
115 |
116 | ))}
117 |
118 |
119 | )}
120 |
121 | {/* Tag Filter Buttons */}
122 |
123 |
124 |
125 | Filter by Tags:
126 |
127 |
128 | {availableTags.map(tag => (
129 | {
132 | const newTags = filterOptions.tags.includes(tag)
133 | ? filterOptions.tags.filter(t => t !== tag)
134 | : [...filterOptions.tags, tag];
135 | setFilterOptions({ tags: newTags });
136 | }}
137 | className={`liquid-glass px-3 py-1.5 rounded-lg transition-all duration-300 flex items-center gap-2 ${
138 | filterOptions.tags.includes(tag)
139 | ? 'bg-primary-500/20 text-primary-400 border border-primary-500/30'
140 | : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700 border border-white/10'
141 | }`}
142 | >
143 |
144 | {tag}
145 | {filterOptions.tags.includes(tag) && (
146 |
147 | {filterOptions.tags.filter(t => t === tag).length}
148 |
149 | )}
150 |
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 |
navigate('/gallery/new')}
166 | className="mt-4 px-4 py-2 liquid-glass bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-all duration-300"
167 | >
168 | Add Your First Build
169 |
170 |
171 | ) : (
172 |
173 | {filteredItems.map((item) => (
174 |
navigate(`/gallery/${item.id}`)}
178 | >
179 |
180 | {item.imageUrls[0] ? (
181 |
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 |
navigate('/builds/new')}
63 | className="liquid-glass flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-all duration-300"
64 | >
65 |
66 | New Build
67 |
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 |
{
86 | const newStatus = filterOptions.status.includes('planning')
87 | ? filterOptions.status.filter(s => s !== 'planning')
88 | : [...filterOptions.status, 'planning'] as ('planning' | 'in-progress' | 'completed' | 'archived')[];
89 | setFilterOptions({ status: newStatus });
90 | }}
91 | className={`liquid-glass px-3 py-1.5 rounded-lg transition-all duration-300 flex items-center gap-2 ${
92 | filterOptions.status.includes('planning')
93 | ? 'bg-blue-500/20 text-blue-400'
94 | : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
95 | }`}
96 | >
97 |
98 | Planning
99 |
100 |
101 |
{
103 | const newStatus = filterOptions.status.includes('in-progress')
104 | ? filterOptions.status.filter(s => s !== 'in-progress')
105 | : [...filterOptions.status, 'in-progress'] as ('planning' | 'in-progress' | 'completed' | 'archived')[];
106 | setFilterOptions({ status: newStatus });
107 | }}
108 | className={`liquid-glass px-3 py-1.5 rounded-lg transition-all duration-300 flex items-center gap-2 ${
109 | filterOptions.status.includes('in-progress')
110 | ? 'bg-yellow-500/20 text-yellow-400'
111 | : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
112 | }`}
113 | >
114 |
115 | In Progress
116 |
117 |
118 |
{
120 | const newStatus = filterOptions.status.includes('completed')
121 | ? filterOptions.status.filter(s => s !== 'completed')
122 | : [...filterOptions.status, 'completed'] as ('planning' | 'in-progress' | 'completed' | 'archived')[];
123 | setFilterOptions({ status: newStatus });
124 | }}
125 | className={`liquid-glass px-3 py-1.5 rounded-lg transition-all duration-300 flex items-center gap-2 ${
126 | filterOptions.status.includes('completed')
127 | ? 'bg-green-500/20 text-green-400'
128 | : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
129 | }`}
130 | >
131 |
132 | Completed
133 |
134 |
135 |
{
137 | const newStatus = filterOptions.status.includes('archived')
138 | ? filterOptions.status.filter(s => s !== 'archived')
139 | : [...filterOptions.status, 'archived'] as ('planning' | 'in-progress' | 'completed' | 'archived')[];
140 | setFilterOptions({ status: newStatus });
141 | }}
142 | className={`liquid-glass px-3 py-1.5 rounded-lg transition-all duration-300 flex items-center gap-2 ${
143 | filterOptions.status.includes('archived')
144 | ? 'bg-neutral-500/20 text-neutral-400'
145 | : 'bg-neutral-800 text-neutral-400 hover:bg-neutral-700'
146 | }`}
147 | >
148 |
149 | Archived
150 |
151 |
152 |
153 |
154 | {/* Builds Grid */}
155 |
156 | {filteredBuilds.map((build) => (
157 |
navigate(`/builds/${build.id}`)}
161 | >
162 |
163 | {build.imageUrls[0] ? (
164 |
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 |
53 |
54 |
Across 12 categories
55 |
56 |
57 |
58 |
67 |
Pending completion
68 |
69 |
70 |
71 |
80 |
Low stock items
81 |
82 |
83 |
84 |
85 |
86 |
Value
87 |
$2,450
88 |
89 |
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 |
115 | Direct Class Button
116 |
117 |
118 |
119 |
120 | {/* Liquid Inputs */}
121 |
122 | Liquid Inputs
123 |
124 |
125 |
Search Input
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 | Text Input
140 |
145 |
146 |
147 |
148 |
149 | {/* Liquid Icons */}
150 |
151 | Liquid Icons
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
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 |
169 |
170 | Export CSV
171 |
172 |
setIsFormOpen(true)}
174 | className="liquid-glass flex items-center space-x-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-all duration-300"
175 | >
176 |
177 | Add Flight Log
178 |
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 |
handleFilterChange('drone', e.target.value)}
197 | className="liquid-glass px-4 py-2 bg-neutral-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all duration-300"
198 | >
199 | All Drones
200 | {uniqueDrones.map(drone => (
201 | {drone}
202 | ))}
203 |
204 |
handleFilterChange('location', e.target.value)}
207 | className="liquid-glass px-4 py-2 bg-neutral-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all duration-300"
208 | >
209 | All Locations
210 | {uniqueLocations.map(location => (
211 | {location}
212 | ))}
213 |
214 |
215 |
216 | {isFormOpen && (
217 |
218 |
219 |
220 | {currentLog.id ? 'Edit Flight Log' : 'Add Flight Log'}
221 |
222 |
301 |
302 |
303 | )}
304 |
305 |
306 |
307 |
308 |
309 | handleSort('date')}
312 | >
313 |
314 | Date
315 | {sortConfig.key === 'date' && (
316 | sortConfig.direction === 'asc' ? :
317 | )}
318 |
319 |
320 | handleSort('drone')}
323 | >
324 |
325 | Drone
326 | {sortConfig.key === 'drone' && (
327 | sortConfig.direction === 'asc' ? :
328 | )}
329 |
330 |
331 | handleSort('location')}
334 | >
335 |
336 | Location
337 | {sortConfig.key === 'location' && (
338 | sortConfig.direction === 'asc' ? :
339 | )}
340 |
341 |
342 | handleSort('duration')}
345 | >
346 |
347 | Duration
348 | {sortConfig.key === 'duration' && (
349 | sortConfig.direction === 'asc' ? :
350 | )}
351 |
352 |
353 | Notes
354 | Issues
355 | Actions
356 |
357 |
358 |
359 | {filteredAndSortedLogs.map((log) => (
360 |
361 | {log.date}
362 | {log.drone}
363 | {log.location}
364 | {log.duration}
365 | {log.notes}
366 | {log.issues}
367 |
368 | handleEdit(log)}
370 | className="liquid-glass text-neutral-400 hover:text-white mr-3 transition-all duration-300"
371 | >
372 |
373 |
374 | handleDelete(log.id)}
376 | className="liquid-glass text-neutral-400 hover:text-red-500 transition-all duration-300"
377 | >
378 |
379 |
380 |
381 |
382 | ))}
383 |
384 |
385 |
386 |
387 | );
388 | };
389 |
390 | export default FlightLog;
--------------------------------------------------------------------------------