api - blueprint-site/blueprint-create GitHub Wiki
Blueprint uses Appwrite as its primary backend service for authentication, database, and storage functionality.
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
};
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';
Blueprint uses the following collections in Appwrite:
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
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
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
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
Blueprint uses Appwrite's OAuth authentication with the following providers:
- Discord
- GitHub
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);
}
}
- User clicks a login button for a provider
-
handleOAuthLogin
is called with the selected provider - User is redirected to Appwrite's OAuth endpoint
- Appwrite redirects to the provider's authentication page
- User authenticates with the provider
- Provider redirects back to Appwrite with authorization code
- Appwrite creates a session and redirects to the success URL
- The application handles the success redirect and calls
handleOAuthCallback
- User data is fetched and stored in the userStore
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);
}
},
Blueprint uses Appwrite Storage for:
The application uses the following storage buckets:
-
Bucket ID:
addonIcons
- File Types: Image files (PNG, JPG, WebP)
- Max Size: 2MB
- Permissions: Public read, admin write
-
Bucket ID:
schematicFiles
- File Types: Schematic (.nbt) and schema files
- Max Size: 10MB
- Permissions: Public read, authenticated write
-
Bucket ID:
schematicPreviews
- File Types: Image files (PNG, JPG, WebP)
- Max Size: 5MB
- Permissions: Public read, authenticated write
-
Bucket ID:
blogImages
- File Types: Image files (PNG, JPG, WebP)
- Max Size: 5MB
- Permissions: Public read, admin write
-
Bucket ID:
userAvatars
- File Types: Image files (PNG, JPG, WebP)
- Max Size: 1MB
- Permissions: Public read, authenticated write
// 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;
}
};
// 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
);
};
// Example of getting a file download URL
const getFileDownload = (fileId: string, bucketId: string) => {
return storage.getFileDownload(bucketId, fileId);
};
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;
}
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 };
}
}
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
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;
}
};
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;
}
};
- Check if the OAuth provider is configured correctly in Appwrite
- Verify redirect URLs are correct
- Check for CORS issues if using a different domain
- Verify collection/bucket permissions
- Check if the user has the correct role
- Ensure the user is authenticated for protected operations
- Verify the document ID is correct
- Check if the document exists in the collection
- Ensure the user has read permissions for the document
- Check if the Appwrite URL is correct
- Verify the project ID is correct
- Check if Appwrite server is running and accessible
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.
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.
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
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 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 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 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)');
};
Endpoints for managing Create Mod addons:
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;
}
Retrieves a single addon by slug:
const {
data: addon,
isLoading,
error
} = useFetchAddon(slug);
// Response is either an Addon object or null
Creates or updates an addon:
const mutation = useSaveAddon();
// Usage
mutation.mutate(addonData);
// Track status
const isLoading = mutation.isPending;
const error = mutation.error;
Deletes an addon:
const mutation = useDeleteAddon();
// Usage
mutation.mutate(addonId);
Endpoints for managing blog posts:
Retrieves a paginated list of blog posts:
const {
data,
isLoading,
error
} = useFetchBlogs(page, limit);
Retrieves a single blog post by slug:
const {
data: blog,
isLoading,
error
} = useFetchBlog(slug);
Creates or updates a blog post:
const mutation = useSaveBlog();
// Usage
mutation.mutate(blogData);
Deletes a blog post:
const mutation = useDeleteBlog();
// Usage
mutation.mutate(blogId);
Endpoints for managing Create Mod schematics:
Retrieves a paginated list of schematics:
const {
data,
isLoading,
error
} = useFetchSchematics(page, limit);
Retrieves a single schematic by ID:
const {
data: schematic,
isLoading,
error
} = useFetchSchematic(id);
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
}
});
Endpoints for searching content:
Searches for addons based on query and filters:
const {
data,
isLoading,
error
} = useSearchAddons({
query: 'factory',
filters: 'categories = "automation"',
page: 1,
hitsPerPage: 20
});
Searches for schematics based on query and filters:
const {
data,
isLoading,
error
} = useSearchSchematics({
query: 'sorting',
filters: 'tags = "automation"',
page: 1,
hitsPerPage: 20
});
Utility endpoints for application functionality:
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 management utilities:
// Sync with system theme
useSystemThemeSync();
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.
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');
}
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 : [],
};
Blueprint implements a thoughtful caching strategy:
- Stale-While-Revalidate: Show cached data immediately while fetching updates
- Appropriate Stale Times: Set cache durations appropriate to data volatility
- Automatic Refetching: Refetch data when window regains focus
- Query Invalidation: Invalidate related queries on mutations
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 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;
},
});
};
-
Use Pagination: Always paginate large collections
const { data } = useFetchAddons(page, limit);
-
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']), ] );
-
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
-
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 />;
-
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}`); } }
-
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.'); }
-
Targeted Invalidation: Invalidate only affected queries
// Good: Only invalidate related queries queryClient.invalidateQueries({ queryKey: ['addon', slug] }); // Bad: Invalidate everything queryClient.invalidateQueries();
-
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 ); }, });
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.
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
Meilisearch requires the following environment variables:
APP_MEILISEARCH_URL=your_meilisearch_url
APP_MEILISEARCH_API_KEY=your_meilisearch_api_key
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,
});
Blueprint uses the following Meilisearch indexes:
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
}
}
}
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"
]
}
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"
]
}
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,
});
};
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")
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>
);
};
Blueprint synchronizes data from Appwrite to Meilisearch to ensure search indexes stay up-to-date. This is handled through background processes after data changes:
- Real-time Synchronization: Updates are synchronized with a small delay
- 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 });
}
};
Blueprint configures Meilisearch ranking rules to provide the most relevant results:
{
"rankingRules": [
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
"downloads:desc"
]
}
This configuration prioritizes:
- Matching more words from the query
- Having fewer typos
- Having query terms closer together
- Matching more important attributes
- Explicit sort criteria
- Exact matches over partial matches
- Higher download counts
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
});
};
- Query Caching: TanStack Query caches search results to reduce API calls
- Pagination: Results are paginated to improve performance
- Attribute Pruning: Only necessary attributes are returned in search results
- Index Optimization: Indexes are optimized for faster searching
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}`} />;
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 });
};
-
API Key Permissions: Different API keys with appropriate permissions:
- Search-only key for frontend operations
- Admin key for indexing operations (server-side only)
-
Input Sanitization: All user inputs are sanitized before use in search queries
-
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}`;
-
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);
-
Handle Empty Queries: Provide meaningful results for empty searches
// Empty query handling const searchQuery = query.trim() || '*'; // Use * for empty queries to match everything