Nov 24, 2024

Building imgxLab: An Open-Source Lab for Photographers

Building imgxLab: An Open-Source Lab for Photographers

Simple, intuitive, and packed with essential tools like metadata analysis and format converters. All the essential tools that every photographer needs (at some point).

  • Jorge Perez Avatar
    Jorge Perez
    6 min read
  • Intro

    As a photographer, finding reliable and straightforward tools is a headache. Online solutions are often cluttered, overpriced, or compromise your privacy by uploading your photos to external servers.

    I'm a photographer and post almost daily on Instagram, so I'm constantly both using, and looking for better, simpler tools.

    That’s why I created imgxLab, a Next.js open-source lab tailored specifically for photographers, by photographers. Inspired by projects like QuickPic by Theo Browne.

    What is imgxLab?

    imgxLab is a collection of essential tools that every photographer needs at some point. From analyzing metadata to converting image formats, imgxLab offers simple and intuitive solutions without the hassle of creating accounts or relying on internet connectivity. Here are the current tools available:

    • Metadata Analyzer: Quickly view detailed metadata from your photos, including camera settings, lens information, and more.
    • Frame Insets Designer: Add customizable frames to your images, adjusting width, aspect ratios, and background colors to suit your style.
    • Image Compressor: Reduce image file sizes without sacrificing quality, making your photos easier to share and store.
    • WebP to PNG Converter: Easily convert images between WebP and PNG formats for broader compatibility.

    Directory Breakdown

    
    imgxLab/
    ├── public/
       └── imgxlab-hero.png
    ├── src/
       ├── app/
          ├── page.tsx
          └── (tools)/
              ├── metadata-viewer/
                 └── metadata-tool.tsx
                 └── page.tsx
       ├── components/
          ├── shared/
             ├── file-dropzone.tsx
             └── upload-box.tsx
       ├── hooks/
          ├── use-file-uploader.ts
          └── use-local-storage.ts
    ├── styles/
       └── globals.css
    ├── .gitignore
    ├── LICENSE
    ├── README.md
    ├── package.json
    ├── tsconfig.json
    └── next.config.js

    Shared Components

    imgxLab is built with reusability in mind. We use shared components like FileDropzone and UploadBox across all tools, ensuring a consistent user experience and simplifying the addition of new tools in the future. Here’s a glimpse of how these components work:

    1. The FileDropzone Component

    This component provides a drag-and-drop interface for file uploads, handling various states like dragging, dropping, and validating file types. Its flexibility allows it to be integrated seamlessly into any tool that requires file uploads.

    // src/components/shared/file-dropzone.tsx
    
    import React, { useCallback, useState, useRef } from "react";
    
    interface FileDropzoneProps {
      children: React.ReactNode;
      acceptedFileTypes: string[];
      dropText: string;
      setCurrentFile: (file: File) => void;
    }
    
    export function FileDropzone({
      children,
      acceptedFileTypes,
      dropText,
      setCurrentFile,
    }: FileDropzoneProps) {
      const [isDragging, setIsDragging] = useState(false);
      const dragCounter = useRef(0);
    
      const handleDrag = useCallback((e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        e.stopPropagation();
      }, []);
    
      const handleDragIn = useCallback((e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        e.stopPropagation();
        dragCounter.current++;
    
        if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) {
          setIsDragging(true);
        }
      }, []);
    
      const handleDragOut = useCallback((e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault();
        e.stopPropagation();
        dragCounter.current--;
    
        if (dragCounter.current === 0) {
          setIsDragging(false);
        }
      }, []);
    
      const handleDrop = useCallback(
        (e: React.DragEvent<HTMLDivElement>) => {
          e.preventDefault();
          e.stopPropagation();
          setIsDragging(false);
          dragCounter.current = 0;
    
          const files = e.dataTransfer?.files;
          if (files && files.length > 0) {
            const droppedFile = files[0];
    
            if (!droppedFile) {
              alert("No files were uploaded. Please ensure you drag and drop valid files.");
              throw new Error("File upload error: No files detected.");
            }
    
            const fileName = droppedFile.name.toLowerCase();
            const fileType = droppedFile.type.toLowerCase();
            const fileExtension = fileName.substring(fileName.lastIndexOf("."));
    
            // Normalize accepted file types for consistent comparison
            const normalizedAcceptedTypes = acceptedFileTypes.map((type) =>
              type.replace("*", "").toLowerCase()
            );
    
            // Validate by MIME type or common image extensions
            const isValidType =
              normalizedAcceptedTypes.includes(fileType) ||
              (normalizedAcceptedTypes.includes("image/") &&
                [".jpg", ".jpeg", ".png", ".webp", ".svg"].includes(fileExtension));
    
            if (!isValidType) {
              alert("Invalid file type. Please upload a supported file type.");
              return;
            }
    
            // Happy path
            setCurrentFile(droppedFile);
          }
        },
        [acceptedFileTypes, setCurrentFile]
      );
    
      return (
        <div
          onDragEnter={handleDragIn}
          onDragLeave={handleDragOut}
          onDragOver={handleDrag}
          onDrop={handleDrop}
          className="h-full w-full"
        >
          {isDragging && (
            <div className="fixed inset-0 z-50 flex items-center justify-center">
              <div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
              <div className="animate-in fade-in zoom-in relative flex h-[80%] w-[80%] transform items-center justify-center border border-white/20 transition-all duration-300 ease-out rounded-xl bg-zinc-900">
                <p className="text-2xl font-semibold text-white">{dropText}</p>
              </div>
            </div>
          )}
          {children}
        </div>
      );
    }
    1. The UploadBox Component

    Complementing FileDropzone, UploadBox provides a user-friendly interface with clear instructions and visual cues for uploading files.

    // src/components/shared/upload-box.tsx
    
    import React from "react";
    import { UploadIcon } from "lucide-react";
    
    interface UploadBoxProps {
      title: string;
      description: string;
      accept: string;
      onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
    }
    
    export function UploadBox({
      title,
      description,
      accept,
      onChange,
    }: UploadBoxProps) {
      return (
        <div className="flex flex-col items-center justify-center gap-6">
          <div className="flex flex-col items-center gap-2">
            <p className="text-balance text-lg text-center text-zinc-300">{title}</p>
          </div>
          <div className="flex w-full flex-col items-center justify-center gap-8 rounded-xl border border-white/20 bg-zinc-900 p-10">
            <div className="flex items-center space-x-3">
              <UploadIcon className="h-6 w-6 text-zinc-300" />
              <p className="font-semibold text-zinc-300">Drag & Drop</p>
            </div>
            <p className="text-base text-zinc-500">Or</p>
            <label className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-zinc-600 px-4 py-2 font-semibold text-white shadow-md transition-colors duration-200 hover:bg-zinc-700 focus:outline-none focus:ring-2 focus:ring-zinc-500 focus:ring-opacity-75">
              <span>{description}</span>
              <input
                type="file"
                onChange={onChange}
                accept={accept}
                className="hidden"
              />
            </label>
          </div>
        </div>
      );
    }

    Local Storage: Ensuring Privacy and Offline Capability

    All user data and settings are stored locally, ensuring that no files are ever uploaded or shared externally.

    1. The useLocalStorage Hook

    The useLocalStorage hook abstracts the logic for interacting with local storage, providing a seamless and persistent state management solution across different tools.

    // src/hooks/use-local-storage.ts
    
    import { useState } from "react";
    
    export function useLocalStorage<T>(key: string, initialValue: T) {
      const [storedValue, setStoredValue] = useState<T>(() => {
        if (typeof window === "undefined") return initialValue;
    
        try {
          const item = window.localStorage.getItem(key);
          return item ? (JSON.parse(item) as T) : initialValue;
        } catch (error) {
          console.warn(`Error reading localStorage key "${key}":`, error);
          return initialValue;
        }
      });
    
      const setValue = (value: T | ((val: T) => T)) => {
        try {
          const valueToStore =
            value instanceof Function ? value(storedValue) : value;
          setStoredValue(valueToStore);
          if (typeof window !== "undefined") {
            window.localStorage.setItem(key, JSON.stringify(valueToStore));
          }
        } catch (error) {
          console.warn(`Error setting localStorage key "${key}":`, error);
        }
      };
    
      return [storedValue, setValue] as const;
    }
    

    This hook ensures that any state managed through it persists across browser sessions, providing users with a consistent experience every time they use imgxLab.

    The Power of Local Storage: Privacy and Offline Functionality

    By processing all data locally within the browser and utilizing local storage, we ensure that:

    • Data Remains Private: No images or metadata are ever uploaded to external servers.
    • Offline Capability: Tools function entirely offline, allowing users to work without an internet connection.
    • Persistent Settings: User preferences and tool settings are saved between sessions, enhancing the user experience.

    This architecture empowers photographers to use imgxLab with complete peace of mind, knowing their data is secure and accessible anytime.

    Breakdown of Each Tool

    I will create dedicated blog posts for each of the current and future tools of imgxLab, focusing on the code and the reasoning behind them. This section will be updated with links as the blog posts are published. Stay tuned!

    A Nod to Inspiration: Respecting QuickPic by Theo Browne

    imgxLab draws inspiration from QuickPic by Theo Browne, a great developer, and a great project that exemplifies the power of open-source collaboration. imgxLab builds upon those concepts, enhancing and expanding them to cater to the photography niche.

    You Might Also Like...