api - blueprint-site/blueprint-create GitHub Wiki

Appwrite Integration

Blueprint uses Appwrite as its primary backend service for authentication, database, and storage functionality.

Setup

Environment Configuration

Appwrite requires the following environment variables:

APP_APPWRITE_URL=your_appwrite_url
APP_APPWRITE_PROJECT_ID=your_project_id

These variables are read from the env.js file at runtime:

// public/env.js
window._env_ = {
  APP_APPWRITE_URL: "https://your-appwrite-instance.com/v1",
  APP_APPWRITE_PROJECT_ID: "your-project-id",
  // ... other variables
};

Client Configuration

The Appwrite client is configured in /src/config/appwrite.ts:

import { Client, Account, Databases, Storage } from 'appwrite';

export const client = new Client();

const url = window._env_?.APPWRITE_URL || '';
const id = window._env_?.APPWRITE_PROJECT_ID || '';

client.setEndpoint(url).setProject(id);

export const databases = new Databases(client);
export const account = new Account(client);
export const storage = new Storage(client);
export { ID } from 'appwrite';

Database Structure

Collections

Blueprint uses the following collections in Appwrite:

Addons Collection

Stores information about Create Mod addons.

Key Fields:

  • name: The display name of the addon
  • description: Markdown description of the addon
  • slug: URL-friendly identifier
  • author: Creator of the addon
  • categories: Array of category tags
  • downloads: Number of downloads
  • icon: URL to the addon's icon
  • sources: Array of data sources (curseforge, modrinth)
  • loaders: Array of supported mod loaders (forge, fabric, neoforge)
  • minecraft_versions: Array of compatible Minecraft versions
  • create_versions: Array of compatible Create Mod versions
  • curseforge_raw: Raw JSON data from CurseForge API
  • modrinth_raw: Raw JSON data from Modrinth API

Indexes:

  • slug (unique): Fast lookups by URL slug
  • name: Search by addon name
  • categories: Filter by categories
  • loaders: Filter by mod loader
  • minecraft_versions: Filter by MC version
  • create_versions: Filter by Create version

Schematics Collection

Stores information about user-created schematics.

Key Fields:

  • name: The display name of the schematic
  • description: Markdown description of the schematic
  • slug: URL-friendly identifier
  • author: Username of the creator
  • user_id: Appwrite user ID of the creator
  • categories: Array of category tags
  • downloads: Number of downloads
  • image: URL to the preview image
  • file: URL to the schematic file
  • minecraft_versions: Array of compatible Minecraft versions
  • create_versions: Array of compatible Create Mod versions

Indexes:

  • slug (unique): Fast lookups by URL slug
  • user_id: Find schematics by creator
  • categories: Filter by categories
  • minecraft_versions: Filter by MC version
  • create_versions: Filter by Create version

Blogs Collection

Stores blog posts.

Key Fields:

  • title: Blog post title
  • content: Markdown content of the post
  • slug: URL-friendly identifier
  • author: Username of the author
  • tags: Array of topic tags
  • published: Boolean indicating if post is published
  • publishDate: Date when post was/will be published
  • coverImage: URL to post cover image

Indexes:

  • slug (unique): Fast lookups by URL slug
  • tags: Filter by tags
  • published: Only show published posts
  • publishDate: Sort by publish date

Users Collection

Extends Appwrite's native users with additional profile information.

Key Fields:

  • name: Display name
  • avatar: URL to profile picture
  • bio: User's biography
  • social_links: Object with social media links
  • collections: Array of collection IDs

Indexes:

  • name: Search by username

Authentication

Blueprint uses Appwrite's OAuth authentication with the following providers:

  • Discord
  • GitHub
  • Google

Authentication is implemented in the userStore.ts using Zustand:

// Simplified example from userStore.ts
handleOAuthLogin: async (provider: 'google' | 'github' | 'discord') => {
  try {
    const providerMap = {
      google: OAuthProvider.Google,
      github: OAuthProvider.Github,
      discord: OAuthProvider.Discord,
    };
    const oauthProvider = providerMap[provider];
    const successUrl = window._env_?.APP_URL + '/auth/success';
    const errorUrl = window._env_?.APP_URL + '/auth/error';
    account.createOAuth2Session(oauthProvider, successUrl, errorUrl);
    return Promise.resolve();
  } catch (error) {
    console.error('Error during OAuth authentication', error);
    return Promise.reject(error);
  }
}

Authentication Flow

  1. User clicks a login button for a provider
  2. handleOAuthLogin is called with the selected provider
  3. User is redirected to Appwrite's OAuth endpoint
  4. Appwrite redirects to the provider's authentication page
  5. User authenticates with the provider
  6. Provider redirects back to Appwrite with authorization code
  7. Appwrite creates a session and redirects to the success URL
  8. The application handles the success redirect and calls handleOAuthCallback
  9. User data is fetched and stored in the userStore

Session Management

Sessions are automatically managed by Appwrite. The application checks for an existing session on startup:

// From userStore.ts
fetchUser: async () => {
  try {
    const userData = await account.get();
    set({
      user: userData as User,
      preferences: userData.prefs as UserPreferences,
    });
  } catch (error) {
    console.log('User is not authenticated');
  }
},

To log out, the current session is deleted:

// From userStore.ts
logout: async () => {
  try {
    await account.deleteSession('current');
    set({ user: null, preferences: null });
    return Promise.resolve();
  } catch (error) {
    console.error('Logout failed', error);
    return Promise.reject(error);
  }
},

Storage

Blueprint uses Appwrite Storage for:

Buckets

The application uses the following storage buckets:

Addon Icons

  • Bucket ID: addonIcons
  • File Types: Image files (PNG, JPG, WebP)
  • Max Size: 2MB
  • Permissions: Public read, admin write

Schematic Files

  • Bucket ID: schematicFiles
  • File Types: Schematic (.nbt) and schema files
  • Max Size: 10MB
  • Permissions: Public read, authenticated write

Schematic Previews

  • Bucket ID: schematicPreviews
  • File Types: Image files (PNG, JPG, WebP)
  • Max Size: 5MB
  • Permissions: Public read, authenticated write

Blog Images

  • Bucket ID: blogImages
  • File Types: Image files (PNG, JPG, WebP)
  • Max Size: 5MB
  • Permissions: Public read, admin write

User Avatars

  • Bucket ID: userAvatars
  • File Types: Image files (PNG, JPG, WebP)
  • Max Size: 1MB
  • Permissions: Public read, authenticated write

File Operations

Uploading Files

// Example of uploading a schematic file
const uploadSchematicFile = async (file: File, fileName: string) => {
  try {
    const result = await storage.createFile(
      'schematicFiles',
      ID.unique(),
      file,
      [`fileName=${fileName}`]
    );
    
    return result;
  } catch (error) {
    console.error('Failed to upload schematic file:', error);
    throw error;
  }
};

Getting File Preview URLs

// Example of getting a file preview URL
const getFilePreview = (fileId: string, bucketId: string) => {
  return storage.getFilePreview(
    bucketId,
    fileId,
    800,  // width
    600,  // height
    'center',  // gravity
    100  // quality
  );
};

Downloading Files

// Example of getting a file download URL
const getFileDownload = (fileId: string, bucketId: string) => {
  return storage.getFileDownload(bucketId, fileId);
};

Best Practices

Error Handling

Always handle Appwrite errors gracefully:

// Pattern for handling Appwrite errors
try {
  await databases.createDocument(/* ... */);
} catch (error) {
  // Check for specific error types
  if (error instanceof AppwriteException) {
    if (error.code === 409) {
      // Conflict error (e.g., duplicate unique value)
      console.error('A document with this slug already exists');
    } else if (error.code === 401) {
      // Unauthorized error
      console.error('Authentication required');
    } else {
      // Other Appwrite-specific errors
      console.error('Appwrite error:', error.message);
    }
  } else {
    // Generic error handling
    console.error('Failed to create document:', error);
  }
  
  // Rethrow or handle as needed
  throw error;
}

Transactions

Appwrite doesn't support transactions directly, so implement your own transaction-like patterns:

// Example of a transaction-like pattern
async function createSchematicWithImage(schematic, image) {
  let schematicId = null;
  let imageId = null;
  
  try {
    // Step 1: Create schematic document
    const doc = await databases.createDocument(
      DATABASE_ID,
      SCHEMATICS_COLLECTION_ID,
      ID.unique(),
      schematic
    );
    schematicId = doc.$id;
    
    // Step 2: Upload image with reference to schematic
    const imageFile = await storage.createFile(
      'schematicPreviews',
      ID.unique(),
      image,
      [`schematicId=${schematicId}`]
    );
    imageId = imageFile.$id;
    
    // Step 3: Update schematic with image ID
    await databases.updateDocument(
      DATABASE_ID,
      SCHEMATICS_COLLECTION_ID,
      schematicId,
      { image: imageId }
    );
    
    return { success: true, schematicId, imageId };
  } catch (error) {
    // Cleanup on failure - roll back changes
    if (imageId) {
      await storage.deleteFile('schematicPreviews', imageId);
    }
    
    if (schematicId) {
      await databases.deleteDocument(
        DATABASE_ID,
        SCHEMATICS_COLLECTION_ID,
        schematicId
      );
    }
    
    return { success: false, error };
  }
}

Permissions

Set appropriate permissions for collections and buckets:

  • Public Resources: Use read permissions for everyone
  • User Content: Restrict write access to the content creator
  • Admin Content: Restrict write access to administrators
  • Sensitive Data: Use role-based permissions

Pagination

Implement proper pagination for listing documents:

// Example of pagination
const getAddons = async (limit = 20, offset = 0) => {
  try {
    const response = await databases.listDocuments(
      DATABASE_ID,
      ADDONS_COLLECTION_ID,
      [
        Query.limit(limit),
        Query.offset(offset),
        Query.orderDesc('$createdAt')
      ]
    );
    
    return {
      documents: response.documents,
      total: response.total
    };
  } catch (error) {
    console.error('Error fetching addons:', error);
    throw error;
  }
};

Data Validation

Always validate data before sending it to Appwrite:

// Example of data validation with Zod
const createSchematic = async (data: unknown) => {
  try {
    // Validate data against schema
    const validatedData = SchematicSchema.parse(data);
    
    // Proceed with creating document
    const document = await databases.createDocument(
      DATABASE_ID,
      SCHEMATICS_COLLECTION_ID,
      ID.unique(),
      validatedData
    );
    
    return document;
  } catch (error) {
    if (error instanceof z.ZodError) {
      // Handle validation errors
      console.error('Validation failed:', error.format());
      throw new Error('Invalid schematic data');
    }
    
    console.error('Error creating schematic:', error);
    throw error;
  }
};

Troubleshooting

Common Issues

Authentication Failures

  • Check if the OAuth provider is configured correctly in Appwrite
  • Verify redirect URLs are correct
  • Check for CORS issues if using a different domain

Permission Errors

  • Verify collection/bucket permissions
  • Check if the user has the correct role
  • Ensure the user is authenticated for protected operations

Document Not Found

  • Verify the document ID is correct
  • Check if the document exists in the collection
  • Ensure the user has read permissions for the document

Server Connection Issues

  • Check if the Appwrite URL is correct
  • Verify the project ID is correct
  • Check if Appwrite server is running and accessible

Resources

API Endpoints

Blueprint implements a comprehensive set of API endpoints to interact with the backend services. This document outlines the available endpoints, their usage, and implementation details.

Overview

Blueprint's API layer is built around TanStack Query hooks that interact with Appwrite and Meilisearch services. These hooks provide a clean, declarative way to fetch, create, update, and delete data.

Endpoint Organization

API endpoints are organized in the /src/api/endpoints directory, with each file focusing on a specific feature or data type:

src/api/endpoints/
├── useAddons.tsx            # Addon CRUD operations
├── useBlogs.tsx             # Blog post management
├── useBreakpoints.tsx       # Responsive design hooks
├── useSearchAddons.tsx      # Addon search functionality
├── useSearchSchematics.tsx  # Schematic search functionality
├── useSystemThemeSync.tsx   # Theme synchronization

Core Endpoint Types

Query Endpoints

Query endpoints retrieve data from backend services using TanStack Query's useQuery hook:

// src/api/endpoints/useAddons.tsx
export const useFetchAddon = (slug?: string) => {
  return useQuery<Addon | null>({
    queryKey: ['addon', slug],
    queryFn: async () => {
      if (!slug) return null;

      const response = await databases.listDocuments(DATABASE_ID, COLLECTION_ID, [
        Query.equal('slug', slug),
      ]);

      if (response.documents.length === 0) return null;
      
      const doc = response.documents[0];
      // Transform document to Addon type
      const addonData: Addon = {
        // Mapping properties
      };

      return addonData;
    },
    enabled: Boolean(slug),
    staleTime: 1000 * 60 * 5, // Cache for 5 minutes
  });
};

Mutation Endpoints

Mutation endpoints modify data using TanStack Query's useMutation hook:

// src/api/endpoints/useAddons.tsx
export const useSaveAddon = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (addon: Partial<Addon>) => {
      const serializedAddon = {
        // Prepare data for storage
      };

      if (!addon.$id) {
        return databases.createDocument(DATABASE_ID, COLLECTION_ID, ID.unique(), serializedAddon);
      }

      return databases.updateDocument(DATABASE_ID, COLLECTION_ID, addon.$id, serializedAddon);
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['addons'] });
    },
  });
};

Search Endpoints

Search endpoints interact with Meilisearch to provide search functionality:

// src/api/endpoints/useSearchAddons.tsx
export const useSearchAddons = ({
  query,
  filters,
  page = 1,
  hitsPerPage = 20,
}: SearchAddonsParams) => {
  return useQuery<SearchResults<Addon>>({
    queryKey: ['search', 'addons', query, filters, page, hitsPerPage],
    queryFn: async () => {
      const index = meilisearch.index('addons');
      const results = await index.search(query, {
        filter: filters,
        page,
        hitsPerPage,
      });
      
      return {
        hits: results.hits as Addon[],
        total: results.estimatedTotalHits,
        page: results.page,
        // Other result metadata
      };
    },
    // Configuration options
  });
};

Utility Endpoints

Utility endpoints provide application-specific functionality:

// src/api/endpoints/useBreakpoints.tsx
export const useIsDesktop = () => {
  return useMediaQuery('(min-width: 1024px)');
};

export const useIsTablet = () => {
  return useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
};

export const useIsMobile = () => {
  return useMediaQuery('(max-width: 767px)');
};

Addon Endpoints

Endpoints for managing Create Mod addons:

useFetchAddons

Retrieves a paginated list of addons:

const { 
  data, 
  isLoading, 
  error 
} = useFetchAddons(page, limit);

// Response structure
interface FetchAddonsResponse {
  addons: Addon[];
  total: number;
  hasNextPage: boolean;
  hasPreviousPage: boolean;
}

useFetchAddon

Retrieves a single addon by slug:

const { 
  data: addon, 
  isLoading, 
  error 
} = useFetchAddon(slug);

// Response is either an Addon object or null

useSaveAddon

Creates or updates an addon:

const mutation = useSaveAddon();

// Usage
mutation.mutate(addonData);

// Track status
const isLoading = mutation.isPending;
const error = mutation.error;

useDeleteAddon

Deletes an addon:

const mutation = useDeleteAddon();

// Usage
mutation.mutate(addonId);

Blog Endpoints

Endpoints for managing blog posts:

useFetchBlogs

Retrieves a paginated list of blog posts:

const {
  data,
  isLoading,
  error
} = useFetchBlogs(page, limit);

useFetchBlog

Retrieves a single blog post by slug:

const {
  data: blog,
  isLoading,
  error
} = useFetchBlog(slug);

useSaveBlog

Creates or updates a blog post:

const mutation = useSaveBlog();

// Usage
mutation.mutate(blogData);

useDeleteBlog

Deletes a blog post:

const mutation = useDeleteBlog();

// Usage
mutation.mutate(blogId);

Schematic Endpoints

Endpoints for managing Create Mod schematics:

useFetchSchematics

Retrieves a paginated list of schematics:

const {
  data,
  isLoading,
  error
} = useFetchSchematics(page, limit);

useFetchSchematic

Retrieves a single schematic by ID:

const {
  data: schematic,
  isLoading,
  error
} = useFetchSchematic(id);

useUploadSchematic

Uploads a new schematic:

const mutation = useUploadSchematic();

// Usage
mutation.mutate({
  file: schematicFile,
  metadata: {
    name: 'My Schematic',
    description: 'A detailed description',
    tags: ['automation', 'factory'],
    // Other metadata
  }
});

Search Endpoints

Endpoints for searching content:

useSearchAddons

Searches for addons based on query and filters:

const {
  data,
  isLoading,
  error
} = useSearchAddons({
  query: 'factory',
  filters: 'categories = "automation"',
  page: 1,
  hitsPerPage: 20
});

useSearchSchematics

Searches for schematics based on query and filters:

const {
  data,
  isLoading,
  error
} = useSearchSchematics({
  query: 'sorting',
  filters: 'tags = "automation"',
  page: 1,
  hitsPerPage: 20
});

Utility Endpoints

Utility endpoints for application functionality:

Breakpoint Utilities

Responsive design utility hooks:

// Check current device type
const isDesktop = useIsDesktop();
const isTablet = useIsTablet();
const isMobile = useIsMobile();

// Get current breakpoint as a string
const breakpoint = useCurrentBreakpoint(); // 'mobile', 'tablet', or 'desktop'

Theme Utilities

Theme management utilities:

// Sync with system theme
useSystemThemeSync();

Implementation Details

Query Key Management

Blueprint follows a consistent pattern for query keys:

  • Single entity: [entityType, id]
  • Collection: [entityType, page, limit]
  • Search: ['search', entityType, query, filters, page, limit]

This pattern ensures proper caching and invalidation of queries.

Error Handling

API endpoints implement consistent error handling:

// Common error handling pattern
try {
  // API operation
  return result;
} catch (error) {
  console.error('Operation failed:', error);
  throw new Error('Failed to perform operation');
}

API Response Transformation

Raw API responses are transformed into application data structures:

// Example transformation
const addonData: Addon = {
  $id: doc.$id,
  name: doc.name || '',
  description: doc.description || '',
  // Map other properties
  
  // Transform complex properties
  curseforge_raw: doc.curseforge_raw ? JSON.parse(doc.curseforge_raw) : undefined,
  
  // Ensure arrays for collections
  categories: Array.isArray(doc.categories) ? doc.categories : [],
};

Caching Strategy

Blueprint implements a thoughtful caching strategy:

  1. Stale-While-Revalidate: Show cached data immediately while fetching updates
  2. Appropriate Stale Times: Set cache durations appropriate to data volatility
  3. Automatic Refetching: Refetch data when window regains focus
  4. Query Invalidation: Invalidate related queries on mutations

Authentication Handling

API endpoints that require authentication are protected using Appwrite's session management:

// Example of a protected endpoint
const useFetchUserData = () => {
  return useQuery({
    queryKey: ['user', 'data'],
    queryFn: async () => {
      try {
        // This will fail if not authenticated
        const userData = await account.get();
        return userData;
      } catch (error) {
        if (error.code === 401) {
          throw new Error('Authentication required');
        }
        throw error;
      }
    },
  });
};

File Upload Handling

File uploads are managed through Appwrite Storage:

// Example file upload
const useUploadFile = () => {
  return useMutation({
    mutationFn: async ({ file, metadata }: { file: File, metadata: Record<string, any> }) => {
      // Upload file to storage
      const fileResponse = await storage.createFile(
        STORAGE_BUCKET_ID,
        ID.unique(),
        file,
        metadata
      );
      return fileResponse;
    },
  });
};

Best Practices

Optimizing Query Performance

  1. Use Pagination: Always paginate large collections

    const { data } = useFetchAddons(page, limit);
  2. Select Specific Fields: Only request needed fields

    // Appwrite example
    const response = await databases.listDocuments(
      DATABASE_ID,
      COLLECTION_ID,
      [
        Query.limit(limit),
        Query.offset((page - 1) * limit),
        Query.select(['$id', 'name', 'description', 'icon']),
      ]
    );
  3. Appropriate Cache Times: Match cache duration to data volatility

    // Frequently changing data
    staleTime: 1000 * 60, // 1 minute
    
    // Relatively stable data
    staleTime: 1000 * 60 * 5, // 5 minutes

Error Handling Guidelines

  1. Graceful Error Recovery: Provide fallbacks when possible

    const { data, error, isLoading } = useFetchAddons(1, 10);
    
    if (isLoading) return <LoadingState />;
    if (error) return <ErrorState error={error} onRetry={() => refetch()} />;
    if (!data || data.addons.length === 0) return <EmptyState />;
  2. Specific Error Messages: Provide actionable error information

    try {
      // Operation
    } catch (error) {
      if (error.code === 404) {
        throw new Error('Addon not found. It may have been deleted.');
      } else if (error.code === 403) {
        throw new Error('You do not have permission to access this addon.');
      } else {
        throw new Error(`Failed to fetch addon: ${error.message}`);
      }
    }
  3. Error Logging: Log detailed errors while showing user-friendly messages

    try {
      // Operation
    } catch (error) {
      console.error('Detailed error:', error);
      throw new Error('A problem occurred while loading data. Please try again.');
    }

Query Invalidation Strategy

  1. Targeted Invalidation: Invalidate only affected queries

    // Good: Only invalidate related queries
    queryClient.invalidateQueries({ queryKey: ['addon', slug] });
    
    // Bad: Invalidate everything
    queryClient.invalidateQueries();
  2. Optimistic Updates: Update UI before server confirmation

    useMutation({
      mutationFn: updateAddon,
      onMutate: async (newAddon) => {
        // Cancel outgoing refetches
        await queryClient.cancelQueries({ queryKey: ['addon', newAddon.slug] });
        
        // Snapshot previous value
        const previousAddon = queryClient.getQueryData(['addon', newAddon.slug]);
        
        // Optimistically update
        queryClient.setQueryData(['addon', newAddon.slug], newAddon);
        
        return { previousAddon };
      },
      onError: (err, newAddon, context) => {
        // Restore previous value on error
        queryClient.setQueryData(
          ['addon', newAddon.slug],
          context.previousAddon
        );
      },
    });

Related Documentation

Meilisearch Integration

Blueprint uses Meilisearch as its search engine, providing fast, typo-tolerant search and powerful filtering capabilities. This document outlines how Meilisearch is integrated into the application.

Overview

Meilisearch is a powerful, fast, open-source search engine that is easy to use and deploy. Blueprint leverages Meilisearch for:

  • Full-text search across addons, schematics, and blog posts
  • Advanced filtering by multiple attributes (versions, categories, etc.)
  • Typo-tolerant search to handle spelling mistakes
  • Fast search results with millisecond response times
  • Relevant ranking based on custom criteria

Setup and Configuration

Environment Configuration

Meilisearch requires the following environment variables:

APP_MEILISEARCH_URL=your_meilisearch_url
APP_MEILISEARCH_API_KEY=your_meilisearch_api_key

Client Configuration

The Meilisearch client is configured in /src/config/meilisearch.ts:

import { MeiliSearch } from 'meilisearch';

const url = window._env_?.APP_MEILISEARCH_URL || '';
const apiKey = window._env_?.APP_MEILISEARCH_API_KEY || '';

export const meilisearch = new MeiliSearch({
  host: url,
  apiKey: apiKey,
});

Index Structure

Blueprint uses the following Meilisearch indexes:

Addons Index

The addons index contains all Create Mod addons with their metadata:

{
  "indexUid": "addons",
  "primaryKey": "$id",
  "searchableAttributes": [
    "name",
    "description",
    "author",
    "categories"
  ],
  "filterableAttributes": [
    "categories",
    "minecraft_versions",
    "create_versions",
    "loaders",
    "author"
  ],
  "sortableAttributes": [
    "downloads",
    "created_at",
    "updated_at"
  ],
  "typoTolerance": {
    "enabled": true,
    "minWordSizeForTypos": {
      "oneTypo": 4,
      "twoTypos": 8
    }
  }
}

Schematics Index

The schematics index contains user-created schematics:

{
  "indexUid": "schematics",
  "primaryKey": "$id",
  "searchableAttributes": [
    "name",
    "description",
    "author",
    "tags"
  ],
  "filterableAttributes": [
    "tags",
    "minecraft_version",
    "create_version",
    "author"
  ],
  "sortableAttributes": [
    "downloads",
    "created_at",
    "updated_at",
    "likes"
  ]
}

Blogs Index

The blogs index contains blog posts:

{
  "indexUid": "blogs",
  "primaryKey": "$id",
  "searchableAttributes": [
    "title",
    "content",
    "author",
    "tags"
  ],
  "filterableAttributes": [
    "tags",
    "author",
    "category"
  ],
  "sortableAttributes": [
    "published_at",
    "updated_at",
    "views"
  ]
}

Search Implementation

Basic Search Hooks

Blueprint implements search functionality through custom hooks in the /src/api/endpoints directory:

// src/api/endpoints/useSearchAddons.tsx
import { useQuery } from '@tanstack/react-query';
import { meilisearch } from '@/config/meilisearch';
import { Addon } from '@/types';

export interface SearchAddonsParams {
  query: string;
  filters?: string;
  page?: number;
  hitsPerPage?: number;
  sort?: string[];
}

export const useSearchAddons = ({
  query,
  filters,
  page = 1,
  hitsPerPage = 20,
  sort,
}: SearchAddonsParams) => {
  return useQuery<{
    hits: Addon[];
    total: number;
    page: number;
    hitsPerPage: number;
    totalPages: number;
  }>({
    queryKey: ['search', 'addons', query, filters, page, hitsPerPage, sort],
    queryFn: async () => {
      try {
        const index = meilisearch.index('addons');
        const results = await index.search(query, {
          filter: filters,
          page,
          hitsPerPage,
          sort,
        });
        
        return {
          hits: results.hits as Addon[],
          total: results.estimatedTotalHits,
          page: results.page,
          hitsPerPage: results.hitsPerPage,
          totalPages: Math.ceil(results.estimatedTotalHits / results.hitsPerPage),
        };
      } catch (error) {
        console.error('Error searching addons:', error);
        throw new Error('Failed to search addons');
      }
    },
    staleTime: 1000 * 60, // Cache for 1 minute
    keepPreviousData: true,
  });
};

Filter Building

To construct complex filters, Blueprint uses a filter builder utility:

// src/lib/search/filterBuilder.ts
export class FilterBuilder {
  private filters: string[] = [];

  /**
   * Add an equality filter
   */
  equals(field: string, value: string | number | boolean): FilterBuilder {
    this.filters.push(`${field} = ${JSON.stringify(value)}`);
    return this;
  }

  /**
   * Add an array contains filter
   */
  contains(field: string, value: string | number): FilterBuilder {
    this.filters.push(`${field} = ${JSON.stringify(value)}`);
    return this;
  }

  /**
   * Add a greater than filter
   */
  greaterThan(field: string, value: number): FilterBuilder {
    this.filters.push(`${field} > ${value}`);
    return this;
  }

  /**
   * Add a less than filter
   */
  lessThan(field: string, value: number): FilterBuilder {
    this.filters.push(`${field} < ${value}`);
    return this;
  }

  /**
   * Add an OR condition group
   */
  or(callback: (builder: FilterBuilder) => void): FilterBuilder {
    const nestedBuilder = new FilterBuilder();
    callback(nestedBuilder);
    const nestedFilters = nestedBuilder.build();
    if (nestedFilters) {
      this.filters.push(`(${nestedFilters})`);
    }
    return this;
  }

  /**
   * Build the final filter string
   */
  build(): string {
    if (this.filters.length === 0) {
      return '';
    }
    return this.filters.join(' AND ');
  }
}

Example usage of the filter builder:

// Example of building complex filters
const filter = new FilterBuilder()
  .contains('minecraft_versions', '1.19.2')
  .or(builder => {
    builder
      .contains('loaders', 'forge')
      .contains('loaders', 'fabric');
  })
  .build();

// Results in: minecraft_versions = "1.19.2" AND (loaders = "forge" OR loaders = "fabric")

Search UI Components

Blueprint includes several components for integrating search into the UI:

// src/components/common/SearchBar.tsx
import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Search } from 'lucide-react';

interface SearchBarProps {
  onSearch: (query: string) => void;
  placeholder?: string;
  initialValue?: string;
}

export const SearchBar: React.FC<SearchBarProps> = ({
  onSearch,
  placeholder = 'Search...',
  initialValue = '',
}) => {
  const [query, setQuery] = useState(initialValue);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSearch(query);
  };

  return (
    <form onSubmit={handleSubmit} className="relative w-full">
      <Input
        type="text"
        placeholder={placeholder}
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        className="pr-10"
      />
      <Button
        type="submit"
        variant="ghost"
        size="sm"
        className="absolute right-0 top-0 h-full"
      >
        <Search className="h-4 w-4" />
      </Button>
    </form>
  );
};

Data Synchronization

Appwrite to Meilisearch Sync

Blueprint synchronizes data from Appwrite to Meilisearch to ensure search indexes stay up-to-date. This is handled through background processes after data changes:

  1. Real-time Synchronization: Updates are synchronized with a small delay
  2. Batch Synchronization: Full reindexing is performed periodically

The synchronization is currently managed through Appwrite Functions:

// Appwrite Function example for syncing an addon to Meilisearch
const { Client } = require('node-appwrite');
const { MeiliSearch } = require('meilisearch');

module.exports = async function(req, res) {
  // Initialize Appwrite client
  const client = new Client()
    .setEndpoint(process.env.APPWRITE_ENDPOINT)
    .setProject(process.env.APPWRITE_PROJECT_ID)
    .setKey(process.env.APPWRITE_API_KEY);

  // Initialize Meilisearch client
  const meilisearch = new MeiliSearch({
    host: process.env.MEILISEARCH_HOST,
    apiKey: process.env.MEILISEARCH_API_KEY,
  });

  // Get addon data from Appwrite
  const databases = new Databases(client);
  const document = req.body;

  // Prepare document for Meilisearch
  const addonData = {
    $id: document.$id,
    name: document.name,
    description: document.description,
    slug: document.slug,
    author: document.author,
    categories: document.categories,
    downloads: document.downloads,
    icon: document.icon,
    minecraft_versions: document.minecraft_versions,
    create_versions: document.create_versions,
    loaders: document.loaders,
    created_at: document.created_at,
    updated_at: document.updated_at,
  };

  // Update document in Meilisearch
  try {
    await meilisearch.index('addons').addDocuments([addonData]);
    console.log(`Synced addon ${document.$id} to Meilisearch`);
    return res.json({ success: true });
  } catch (error) {
    console.error('Error syncing to Meilisearch:', error);
    return res.json({ success: false, error: error.message });
  }
};

Advanced Search Features

Search Ranking

Blueprint configures Meilisearch ranking rules to provide the most relevant results:

{
  "rankingRules": [
    "words",
    "typo",
    "proximity",
    "attribute",
    "sort",
    "exactness",
    "downloads:desc"
  ]
}

This configuration prioritizes:

  1. Matching more words from the query
  2. Having fewer typos
  3. Having query terms closer together
  4. Matching more important attributes
  5. Explicit sort criteria
  6. Exact matches over partial matches
  7. Higher download counts

Faceted Search

Blueprint implements faceted search for filtering results by categories, versions, etc.:

// src/api/endpoints/useSearchFacets.tsx
import { useQuery } from '@tanstack/react-query';
import { meilisearch } from '@/config/meilisearch';

export const useAddonFacets = (query: string, filters?: string) => {
  return useQuery({
    queryKey: ['facets', 'addons', query, filters],
    queryFn: async () => {
      const index = meilisearch.index('addons');
      const results = await index.search(query, {
        filter: filters,
        facets: ['categories', 'minecraft_versions', 'create_versions', 'loaders'],
        limit: 0, // We only need facets, not results
      });
      
      return results.facetDistribution || {};
    },
    staleTime: 1000 * 60 * 5, // Cache for 5 minutes
  });
};

Performance Considerations

  1. Query Caching: TanStack Query caches search results to reduce API calls
  2. Pagination: Results are paginated to improve performance
  3. Attribute Pruning: Only necessary attributes are returned in search results
  4. Index Optimization: Indexes are optimized for faster searching

Error Handling

Blueprint implements error handling for search operations:

// Example of error handling in search hooks
const { data, error, isLoading, isError } = useSearchAddons({
  query,
  filters,
  page,
});

// In component
if (isLoading) return <LoadingOverlay />;
if (isError) return <ErrorMessage message={`Search failed: ${error.message}`} />;

Testing Search Functionality

For testing search functionality, Blueprint provides utility functions:

// src/lib/search/testUtils.ts
export const testSearch = async (query: string, filters?: string) => {
  const index = meilisearch.index('addons');
  return await index.search(query, { filter: filters });
};

Security Considerations

  1. API Key Permissions: Different API keys with appropriate permissions:

    • Search-only key for frontend operations
    • Admin key for indexing operations (server-side only)
  2. Input Sanitization: All user inputs are sanitized before use in search queries

Best Practices

  1. Use Appropriate Filters: Build filters that match user needs

    // Good: Specific filter
    const filter = new FilterBuilder()
      .contains('minecraft_versions', selectedVersion)
      .build();
    
    // Bad: Overly complex filter
    const filter = `minecraft_versions = "${selectedVersion}" AND created_at > ${Date.now() - 86400000}`;
  2. Optimize Query Parameters: Only include necessary parameters

    // Good: Only request what's needed
    const results = await index.search(query, {
      filter: filters,
      limit: 10,
      offset: (page - 1) * 10,
      attributesToRetrieve: ['$id', 'name', 'description', 'icon'],
    });
    
    // Bad: Requesting everything
    const results = await index.search(query);
  3. Handle Empty Queries: Provide meaningful results for empty searches

    // Empty query handling
    const searchQuery = query.trim() || '*'; // Use * for empty queries to match everything

Related Documentation

⚠️ **GitHub.com Fallback** ⚠️