diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..a1b20071 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,1276 @@ +# Feature: Support Arbitrary Icon Variants with Presets and Custom Variants + +## Overview + +Currently, the icon system only supports a limited set of hardcoded variants (`light`, `dark`, `wordmark.light`, `wordmark.dark`). This feature request aims to refactor the system to support: + +1. **Preset variants**: `default`, `light`, `dark`, `wordmark-default`, `wordmark-light`, `wordmark-dark` +2. **Custom variants**: Any arbitrary variant name (e.g., `monochrome`, `outline`, `filled`, `branded`, etc.) +3. **Bulletproof rendering**: The system must be able to display any icon variant without breaking, even if the variant structure is unexpected +4. **Code reuse**: Unify the logic for displaying icons between collection pages (`/icons/[icon]`) and community pages (`/community/[icon]`) to reduce duplication + +## Current Architecture & Data Flow + +### How Icons Are Currently Stored + +#### Collection Icons (Main Repository) +Collection icons are stored in the repository with the following structure: + +1. **File System**: Icons are stored in three directories: + - `/svg/` - SVG format files + - `/png/` - PNG format files + - `/webp/` - WEBP format files + +2. **Metadata Structure** (`metadata.json`): + ```json + { + "icon-name": { + "base": "svg", + "aliases": ["alias1", "alias2"], + "categories": ["category1"], + "update": { + "timestamp": "2024-01-01T00:00:00Z", + "author": { "id": 12345, "login": "username" } + }, + "colors": { + "light": "icon-name-light", + "dark": "icon-name-dark" + }, + "wordmark": { + "light": "icon-name-wordmark-light", + "dark": "icon-name-wordmark-dark" + } + } + } + ``` + +3. **File Naming Convention**: + - Base icon: `icon-name.svg`, `icon-name.png`, `icon-name.webp` + - Light variant: `icon-name-light.svg`, `icon-name-light.png`, `icon-name-light.webp` + - Dark variant: `icon-name-dark.svg`, `icon-name-dark.png`, `icon-name-dark.webp` + - Wordmark variants follow the same pattern with `-wordmark-light` and `-wordmark-dark` suffixes + +4. **URL Generation**: URLs are constructed as `${BASE_URL}/${format}/${filename}.${format}` where: + - `BASE_URL` = `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons` + - `format` = `svg`, `png`, or `webp` + - `filename` = the icon name or variant name from metadata + +#### Community Icons (PocketBase Submissions) +Community icons are stored in PocketBase with a different structure: + +1. **Storage**: Files are uploaded to PocketBase's file storage system, which sanitizes and renames files automatically (e.g., `icon.svg` becomes `icon_abc123xyz.svg`) + +2. **Database Structure** (`community_gallery` collection): + - `name`: Icon identifier + - `assets`: Array of sanitized filenames (e.g., `["icon_abc123.svg", "icon_xyz789.png"]`) + - `extras`: JSON object containing: + ```json + { + "aliases": ["alias1"], + "categories": ["category1"], + "base": "svg", + "colors": { + "light": "original-light-filename.svg", + "dark": "original-dark-filename.svg" + }, + "wordmark": { + "light": "original-wordmark-light.svg", + "dark": "original-wordmark-dark.svg" + } + } + ``` + +3. **URL Generation**: URLs are full HTTP URLs to PocketBase file endpoints: + - Base: `${PB_URL}/api/files/community_gallery/${recordId}/${sanitizedFilename}` + - The `transformGalleryToIcon` function in `web/src/lib/community.ts` converts PocketBase records to the `Icon` format used by the display components + +4. **Filename Matching**: Because PocketBase sanitizes filenames, the system uses `findBestMatchingAsset()` to match original filenames (stored in `extras`) to actual sanitized filenames (in `assets` array) + +### Current Display Logic + +The `IconDetails` component (`web/src/components/icon-details.tsx`) is shared between both collection and community pages. It handles the differences through: + +1. **Icon Type Detection**: + ```typescript + const isCommunityIcon = !!communityData.mainIconUrl || + (typeof iconData.base === "string" && iconData.base.startsWith("http")) + ``` + - Collection icons: `iconData.base` is a format string (`"svg"`, `"png"`, `"webp"`) + - Community icons: `iconData.base` is a full HTTP URL or `mainIconUrl` is set + +2. **Variant Rendering** (`renderVariant` function): + - **Collection icons**: Constructs URL as `${BASE_URL}/${format}/${iconName}.${format}` + - **Community icons**: Searches `assetUrls` array to find matching URL by filename and format + - The function receives `iconName` (which may be a variant filename) and `theme` (optional: `"light"` or `"dark"`) + +3. **Variant Display Sections**: + - Base icon: Always shown unless it matches a variant name + - Light variant: Only shown if `iconData.colors?.light` exists + - Dark variant: Only shown if `iconData.colors?.dark` exists + - Wordmark section: Only shown if `iconData.wordmark` exists, then checks for `light` and `dark` properties + +4. **Format Detection**: + - Collection icons: Determined by `iconData.base` (if `"svg"`, shows SVG/PNG/WEBP; if `"png"`, shows PNG/WEBP only) + - Community icons: Extracted from `assetUrls` by file extension + +### Current Submission Flow + +1. **Form Submission** (`web/src/components/advanced-icon-submission-form-tanstack.tsx`): + - User selects variants from hardcoded list: `base`, `dark`, `light`, `wordmark`, `wordmark_dark` + - Files are uploaded to form state + - On submit, `extras` object is built with structure: + ```typescript + { + colors: { dark: filename, light: filename }, + wordmark: { light: filename, dark: filename } + } + ``` + +2. **PocketBase Upload**: + - All files are uploaded as `assets` array + - PocketBase sanitizes filenames + - After upload, the code updates `extras` with sanitized filenames by tracking asset index + +3. **Import Script** (`scripts/import-icon.ts`): + - Fetches submission from PocketBase + - `buildTargets()` function creates target file paths based on `extras.colors` and `extras.wordmark` + - Downloads files and saves with naming convention: `icon-name-variant.${ext}` + - `buildMetadataVariants()` converts targets back to metadata format with hardcoded key mapping + +## Problems with Current Implementation + +### 1. Hardcoded Variant Types + +**Type System** (`web/src/types/icons.ts`): +- `IconColors` only has `light?: string` and `dark?: string` +- `IconWordmarkColors` only has `light?: string` and `dark?: string` +- Cannot represent custom variants like `monochrome`, `outline`, `filled`, etc. + +**Impact**: Any variant that isn't `light` or `dark` cannot be stored or displayed. + +### 2. Duplicated Logic Between Collection and Community + +**URL Resolution**: +- Collection icons: Simple string concatenation in `renderVariant` +- Community icons: Complex array searching and filename matching +- Both paths are embedded in the same function, making it hard to maintain + +**Variant Iteration**: +- Collection icons: Direct property access (`iconData.colors.light`, `iconData.colors.dark`) +- Community icons: Same structure but different URL resolution +- No unified way to iterate over all variants + +### 3. Submission Form Limitations + +**Hardcoded Variants**: +- Form only allows selecting from 5 predefined variants +- Cannot add custom variants +- UI is tightly coupled to specific variant names + +**Submission Payload**: +- `extras` structure assumes only `colors` and `wordmark` with `light`/`dark` +- Asset index tracking is fragile and assumes specific order + +### 4. Import Script Limitations + +**Target Building**: +- `buildTargets()` only checks for `colors.light`, `colors.dark`, `wordmark.light`, `wordmark.dark` +- Cannot handle arbitrary variant names +- Hardcoded filename patterns (`icon-name-light.${ext}`, `icon-name-wordmark-light.${ext}`) + +**Metadata Building**: +- `buildMetadataVariants()` uses hardcoded `if/else` chain to map variant keys +- Cannot handle custom variant names +- Assumes specific key patterns (`wordmark-light`, `wordmark-dark`) + +### 5. Display Component Limitations + +**Variant Sections**: +- `IconVariantsSection` is called separately for each hardcoded variant +- `WordmarkSection` only checks for `wordmark.light` and `wordmark.dark` +- Cannot dynamically render arbitrary variants + +**Technical Details**: +- Only displays `colors.light` and `colors.dark` in the variants list +- Only displays `wordmark.light` and `wordmark.dark` in wordmark list +- Cannot show custom variants + +## Proposed Solution + +### 1. Unified Variant Type System + +**New Type Structure**: +```typescript +// Variant definitions are stored in a separate file (variant-definitions.ts) +// The database stores a simple array of variant names +export type IconVariants = { + [variantName: string]: string // variant name -> filename (without extension) +} + +export type Icon = { + base: string | "svg" | "png" | "webp" + aliases: string[] + categories: string[] + update: IconUpdate + variants?: IconVariants // Replaces colors - flexible object with any variant names + wordmark?: IconVariants // Replaces IconWordmarkColors - flexible object with any variant names + // Keep colors and wordmark for backward compatibility during migration + colors?: { light?: string; dark?: string } + wordmark?: { light?: string; dark?: string } +} +``` + +**Variant Definitions File**: +Create `web/src/lib/variant-definitions.ts` that defines: +- Preset variants (default, light, dark, wordmark-default, wordmark-light, wordmark-dark) +- Metadata for each preset (label, description, icon component) +- Helper functions to get variant definitions (returns preset or generates default for custom variants) + +**Migration Strategy**: +- Support both old format (`colors`) and new format (`variants`) during transition +- Variant definitions file provides metadata for preset variants +- Custom variants get default metadata generated on-the-fly +- All display code uses `getVariantDefinition()` to get metadata for any variant name + +### 2. Variant Definitions System + +**Create Variant Definitions File** (`web/src/lib/variant-definitions.ts`): + +This file defines preset variants and provides utilities to work with both preset and custom variants: + +- Defines preset variants with metadata (label, description, icon component) +- Provides `getVariantDefinition()` function that returns preset metadata or generates default for custom variants +- Provides helper functions to group and categorize variants +- Allows display components to render any variant (preset or custom) with appropriate metadata + +### 3. Unified URL Resolution Utility + +**Create New Utility** (`web/src/lib/icon-url-resolver.ts`): + +This utility will handle URL resolution for both collection and community icons, eliminating duplication: + +```typescript +interface IconUrlContext { + isCommunityIcon: boolean + baseIconName: string + baseFormat: string + // For collection icons + baseUrl?: string + // For community icons + assetUrls?: string[] + mainIconUrl?: string +} + +/** + * Resolves the URL for an icon variant in a specific format + * Works for both collection and community icons + */ +export function resolveIconUrl( + context: IconUrlContext, + variantName: string | null, // null for base icon + format: string +): string | null { + if (context.isCommunityIcon) { + return resolveCommunityIconUrl(context, variantName, format) + } else { + return resolveCollectionIconUrl(context, variantName, format) + } +} + +function resolveCollectionIconUrl( + context: IconUrlContext, + variantName: string | null, + format: string +): string { + const filename = variantName || context.baseIconName + return `${context.baseUrl}/${format}/${filename}.${format}` +} + +function resolveCommunityIconUrl( + context: IconUrlContext, + variantName: string | null, + format: string +): string | null { + const formatExt = format === "svg" ? "svg" : format === "png" ? "png" : "webp" + + if (!variantName) { + // Base icon: return mainIconUrl or find by format + if (context.mainIconUrl?.toLowerCase().endsWith(`.${formatExt}`)) { + return context.mainIconUrl + } + return context.assetUrls?.find(url => + url.toLowerCase().endsWith(`.${formatExt}`) + ) || context.mainIconUrl || null + } + + // Variant: find by matching variant filename in assetUrls + // variantName is the filename (without extension) from metadata + return context.assetUrls?.find(url => { + const urlFilename = url.split('/').pop()?.replace(/\.[^.]+$/, '') || '' + return urlFilename.includes(variantName) && url.toLowerCase().endsWith(`.${formatExt}`) + }) || null +} +``` + +**Benefits**: +- Single source of truth for URL resolution +- Easy to test and maintain +- Can be reused by both collection and community pages +- Handles edge cases in one place + +### 4. Unified Variant Rendering + +**Refactor `IconDetails` Component**: + +Instead of hardcoded sections, create a unified variant rendering system that: +- Uses `getVariantDefinition()` to get metadata for each variant +- Renders preset variants with their defined icons and labels +- Renders custom variants with generated default metadata +- Groups variants by category (regular variants vs wordmark variants) +- Sorts variants (preset first, then custom alphabetically) + +```typescript +import { getVariantDefinition, groupVariantsByCategory } from "@/lib/variant-definitions" + +// Get all variant names from iconData +const allVariants = Object.keys(iconData.variants || {}) +const allWordmarkVariants = Object.keys(iconData.wordmark || {}) + +// Sort: preset variants first, then custom variants alphabetically +const sortedVariants = allVariants.sort((a, b) => { + const aDef = getVariantDefinition(a) + const bDef = getVariantDefinition(b) + if (aDef.preset && !bDef.preset) return -1 + if (!aDef.preset && bDef.preset) return 1 + return a.localeCompare(b) +}) + +const sortedWordmarkVariants = allWordmarkVariants.sort((a, b) => { + const aDef = getVariantDefinition(`wordmark-${a}`) + const bDef = getVariantDefinition(`wordmark-${b}`) + if (aDef.preset && !bDef.preset) return -1 + if (!aDef.preset && bDef.preset) return 1 + return a.localeCompare(b) +}) + +// Render all variants dynamically +{sortedVariants.map((variantName) => { + const variantDef = getVariantDefinition(variantName) + const variantFilename = iconData.variants[variantName] + + return ( + } + availableFormats={availableFormats} + icon={variantFilename || icon} + iconData={iconData} + handleCopy={handleCopyUrl} + handleDownload={handleDownload} + copiedVariants={copiedVariants} + renderVariant={renderVariant} + /> + ) +})} + +// Similar for wordmark variants... +``` + +**Updated `renderVariant` Function**: + +```typescript +const renderVariant = ( + format: string, + variantFilename: string | null, // null for base icon + variantKey: string +) => { + const imageUrl = resolveIconUrl(urlContext, variantFilename, format) + if (!imageUrl) return null // Handle missing variants gracefully + + const githubUrl = !isCommunityIcon && variantFilename + ? `${REPO_PATH}/tree/main/${format}/${variantFilename}.${format}` + : "" + + // ... rest of rendering logic +} +``` + +**Benefits**: +- Single rendering path for all variants +- Automatically handles custom variants +- No hardcoded variant names +- Works for both collection and community icons + +### 4. Enhanced Submission Form + +**Dynamic Variant Management**: + +1. **Preset Variants**: Keep quick-select options for common variants (`default`, `light`, `dark`, `wordmark-default`, `wordmark-light`, `wordmark-dark`) + +2. **Custom Variants**: Add UI to: + - Enter custom variant name (with validation: alphanumeric, hyphens, underscores) + - Select variant type (regular variant or wordmark) + - Upload file for that variant + - Remove custom variants + +3. **Updated Submission Payload**: + ```typescript + { + aliases: string[], + categories: string[], + base: string, + variants?: { [variantName: string]: string }, // New format + wordmark?: { [variantName: string]: string }, // New format + // Keep old format for backward compatibility during migration + colors?: { light?: string; dark?: string }, + wordmark?: { light?: string; dark?: string } + } + ``` + +4. **Asset Index Tracking**: Instead of hardcoded index tracking, use a mapping: + ```typescript + // After upload, create a map of original filename -> sanitized filename + const filenameMap = new Map() + value.files.base?.[0] && filenameMap.set(value.files.base[0].name, record.assets[0]) + // ... track all files + + // Then update extras by looking up in map + Object.keys(extras.variants || {}).forEach(variantName => { + const originalFilename = extras.variants[variantName] + const sanitizedFilename = filenameMap.get(originalFilename) + if (sanitizedFilename) { + extras.variants[variantName] = sanitizedFilename + } + }) + ``` + +### 5. Enhanced Import Script + +**Dynamic Target Building**: + +```typescript +function buildTargets(submission: Submission): VariantTarget[] { + const iconId = submission.name + const ext = inferBase(submission.assets, submission.extras?.base) + + const targets: VariantTarget[] = [ + { key: "base", destFilename: `${iconId}.${ext}` } + ] + + // Handle new variants format + const variants = submission.extras?.variants || {} + Object.entries(variants).forEach(([variantName, filename]) => { + if (filename) { + targets.push({ + key: variantName, + destFilename: `${iconId}-${variantName}.${ext}`, + exactFilename: filename as string + }) + } + }) + + // Handle wordmark variants + const wordmarkVariants = submission.extras?.wordmark || {} + Object.entries(wordmarkVariants).forEach(([variantName, filename]) => { + if (filename) { + targets.push({ + key: `wordmark-${variantName}`, + destFilename: `${iconId}-wordmark-${variantName}.${ext}`, + exactFilename: filename as string + }) + } + }) + + // Migration: Support old colors format + if (submission.extras?.colors && !submission.extras?.variants) { + if (submission.extras.colors.light) { + targets.push({ + key: "light", + destFilename: `${iconId}-light.${ext}`, + exactFilename: submission.extras.colors.light + }) + } + if (submission.extras.colors.dark) { + targets.push({ + key: "dark", + destFilename: `${iconId}-dark.${ext}`, + exactFilename: submission.extras.colors.dark + }) + } + } + + return targets +} +``` + +**Dynamic Metadata Building**: + +```typescript +function buildMetadataVariants(assignments: VariantTarget[]): { + variants?: IconVariants + wordmark?: IconVariants +} { + const variants: IconVariants = {} + const wordmark: IconVariants = {} + + for (const v of assignments) { + if (!v.sourceAsset) continue + + const baseName = v.destFilename.replace(/\.[^.]+$/, "") + + if (v.key === "base") { + // Base icon, skip (handled separately) + continue + } else if (v.key.startsWith("wordmark-")) { + // Wordmark variant: extract variant name + const variantName = v.key.replace("wordmark-", "") + wordmark[variantName] = baseName + } else { + // Regular variant + variants[v.key] = baseName + } + } + + return { + variants: Object.keys(variants).length ? variants : undefined, + wordmark: Object.keys(wordmark).length ? wordmark : undefined + } +} +``` + +### 6. Updated Community Library + +**Enhanced Transformation**: + +The `transformGalleryToIcon` function in `web/src/lib/community.ts` needs to handle the new variant structure: + +```typescript +function transformGalleryToIcon(item: CommunityGallery): any { + // ... existing code ... + + // Process variants (new format) or colors (old format for migration) + const variants = item.extras?.variants ? { ...item.extras.variants } : undefined + if (variants && item.assets) { + Object.keys(variants).forEach((key) => { + variants[key] = findBestMatchingAsset(variants[key], item.assets || []) + }) + } + + // Migration: Convert colors to variants if needed + const colors = item.extras?.colors ? { ...item.extras.colors } : undefined + if (colors && item.assets && !variants) { + // Convert old format to new format + const migratedVariants: IconVariants = {} + Object.keys(colors).forEach((key) => { + const matched = findBestMatchingAsset(colors[key]!, item.assets || []) + migratedVariants[key] = matched + }) + // Use migrated variants + Object.assign(variants || {}, migratedVariants) + } + + // Similar logic for wordmark + const wordmark = item.extras?.wordmark ? { ...item.extras.wordmark } : undefined + if (wordmark && item.assets) { + Object.keys(wordmark).forEach((key) => { + wordmark[key] = findBestMatchingAsset(wordmark[key]!, item.assets || []) + }) + } + + return { + // ... existing fields ... + variants: variants, + wordmark: wordmark, + // Keep old format for backward compatibility + colors: colors, + } +} +``` + +## Implementation Plan + +### Phase 1: Variant Definitions & Type System +1. Create `web/src/lib/variant-definitions.ts` with preset variant definitions +2. Update `web/src/types/icons.ts` with new `IconVariants` type +3. Create `web/src/lib/icon-url-resolver.ts` with unified URL resolution +4. Add migration support for old `colors` format + +### Phase 2: Display Components +1. Refactor `IconDetails` to use `getVariantDefinition()` for all variants +2. Update variant rendering to handle both preset and custom variants +3. Create unified `VariantSection` component that works with variant definitions +4. Update `renderVariant` to use new URL resolver +5. Update technical details section to show all variants dynamically +6. Ensure custom variants get appropriate default styling and labels + +### Phase 3: Submission Form +1. Add UI for custom variant management (text input + file upload) +2. Update form state to handle arbitrary variants +3. Refactor submission payload building to create `variants` array +4. Ensure variant array order matches asset array order (base at 0, variants start at 1) +5. Validate variant names (alphanumeric, hyphens, underscores) + +### Phase 4: Import Script +1. Refactor `buildTargets()` to handle `variants` array format +2. Refactor `buildMetadataVariants()` to build `variants` and `wordmark` objects from assignments +3. Add migration logic to convert old `colors`/`wordmark` format to `variants` array +4. Test with various variant combinations (preset and custom) + +### Phase 5: Community Library +1. Update `transformGalleryToIcon()` to convert `variants` array to display format +2. Map variant array indices to asset array indices (variants[i] → assets[i+1]) +3. Add migration logic to convert old `colors`/`wordmark` format to `variants` array +4. Ensure backward compatibility with old format + +### Phase 6: Metadata Pages +1. Update `web/src/app/icons/[icon]/page.tsx` metadata generation +2. Update `web/src/app/community/[icon]/page.tsx` metadata generation +3. Include all variants in OpenGraph/Twitter metadata + +### Phase 7: Testing & Migration +1. Test with existing icons (backward compatibility) +2. Test with new variant structures +3. Test edge cases (missing variants, malformed data) +4. Update documentation + +## Sub-Task: Wordmark Icon Customizer Support + +### Current Limitation + +The icon customizer (`IconCustomizerInline`) currently only works with base icons. The `getSvgUrl()` function in `IconDetails` only checks for base icon or `colors.light`, and doesn't support wordmark variants. + +### Proposed Solution + +1. **Variant Selector UI**: Add a dropdown/selector in the icon details sidebar to choose which variant to customize: + - Base icon + - All regular variants (from `variants` object) + - All wordmark variants (from `wordmark` object) + +2. **Enhanced SVG URL Resolution**: Refactor `getSvgUrl()` to accept variant selection: + ```typescript + function getSvgUrl( + iconData: Icon, + variantType: 'base' | 'variant' | 'wordmark', + variantName?: string + ): string | null { + const context = buildIconUrlContext(iconData, icon) + + if (variantType === 'base') { + return resolveIconUrl(context, null, 'svg') + } else if (variantType === 'variant' && variantName) { + const normalized = normalizeIconVariants(iconData) + const filename = normalized.variants?.[variantName] + return filename ? resolveIconUrl(context, filename, 'svg') : null + } else if (variantType === 'wordmark' && variantName) { + const normalized = normalizeIconVariants(iconData) + const filename = normalized.wordmark?.[variantName] + return filename ? resolveIconUrl(context, filename, 'svg') : null + } + + return null + } + ``` + +3. **Customizer Component Updates**: + - Update `IconCustomizerInline` to accept variant selection + - Ensure customizer works with all variant types + - Preserve variant-specific styling when applicable + +### Implementation Tasks + +- [ ] Add variant selector UI to icon details page +- [ ] Refactor `getSvgUrl` to support variant selection +- [ ] Update `IconCustomizerInline` to handle variant URLs +- [ ] Test customizer with base, variant, and wordmark icons +- [ ] Update customizer to preserve variant-specific styling when applicable + +## Testing Requirements + +1. **Backward Compatibility**: All existing icons must continue to work with old `colors` format +2. **New Variants**: Test with custom variant names (e.g., `monochrome`, `outline`, `branded`) +3. **Edge Cases**: + - Icons with no variants + - Icons with only custom variants + - Icons with mixed preset and custom variants + - Malformed variant data + - Missing variant files + - Community icons with mismatched filenames +4. **Wordmark Customizer**: Test customizer with all wordmark variants +5. **URL Resolution**: Test URL resolution for both collection and community icons with all variant types + +## Breaking Changes + +⚠️ **Note**: This feature will introduce breaking changes to the metadata structure. A migration strategy must be implemented to support both old and new formats during a transition period. The system should: + +1. Read both old and new formats +2. Normalize to new format internally +3. Write new format when updating metadata +4. Provide migration script to convert existing metadata.json + +## Code Reuse Benefits + +By implementing unified utilities and components: + +1. **Single URL Resolution Logic**: Both collection and community pages use the same `resolveIconUrl()` function +2. **Unified Variant Rendering**: Same rendering logic for all variants, regardless of source +3. **Consistent Behavior**: Collection and community icons behave identically from user perspective +4. **Easier Maintenance**: Changes to variant handling only need to be made in one place +5. **Better Testing**: Utilities can be unit tested independently + +## PocketBase Database Schema Changes + +### Current Problem + +Currently, the `extras` JSON field stores variant references as **filenames** in nested objects, which creates a fragile system: + +**Current Structure** (from your example): +```json +{ + "wordmark": { + "dark": "broadcom_logo_dark_sasr6cj9gt.svg", + "light": "broadcom_logo_cci1fr7y81.svg" + } +} +``` + +**Problems**: +1. PocketBase sanitizes filenames when uploading, so the original filename doesn't match the stored filename +2. The code must use fragile filename matching logic (see [`findBestMatchingAsset`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/lib/community.ts#L24-L53)) +3. Asset order tracking is fragile (see [asset index tracking](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/components/advanced-icon-submission-form-tanstack.tsx#L194-L226)) +4. Hardcoded structure prevents custom variants +5. No separation between variant names and asset references + +### Proposed Solution: Simple Variants Array + +Store variant names as a **simple array of text strings** in the `extras` field. The order of variants in the array corresponds to the order of assets (after the base icon at index 0). + +**New `extras` Format**: +```json +{ + "aliases": ["VMware", "Brocade", "Symantec"], + "base": "svg", + "categories": ["cloud", "security", "network", "storage"], + "variants": ["light", "dark", "wordmark-light", "wordmark-dark"] +} +``` + +**How it works**: +- Base icon is always at `assets[0]` +- First variant in array (`"light"`) corresponds to `assets[1]` +- Second variant (`"dark"`) corresponds to `assets[2]` +- Third variant (`"wordmark-light"`) corresponds to `assets[3]` +- And so on... + +**For custom variants**: +```json +{ + "variants": ["light", "dark", "monochrome", "outline", "wordmark-light", "wordmark-dark"] +} +``` + +### Variant Definitions File + +Create a new TypeScript file that defines preset variants and their metadata: + +**File**: `web/src/lib/variant-definitions.ts` + +```typescript +import { FileImage, FileType, Moon, Sun, Type } from "lucide-react" + +export interface VariantDefinition { + id: string + label: string + description: string + icon: React.ComponentType<{ className?: string }> + category: "variant" | "wordmark" + preset: true // Marks this as a preset variant +} + +export const PRESET_VARIANTS: Record = { + "default": { + id: "default", + label: "Default", + description: "Default icon variant", + icon: FileImage, + category: "variant", + preset: true + }, + "light": { + id: "light", + label: "Light Theme", + description: "Icon optimized for light backgrounds", + icon: Sun, + category: "variant", + preset: true + }, + "dark": { + id: "dark", + label: "Dark Theme", + description: "Icon optimized for dark backgrounds", + icon: Moon, + category: "variant", + preset: true + }, + "wordmark-default": { + id: "wordmark-default", + label: "Wordmark Default", + description: "Wordmark variant with default styling", + icon: Type, + category: "wordmark", + preset: true + }, + "wordmark-light": { + id: "wordmark-light", + label: "Wordmark Light", + description: "Wordmark optimized for light backgrounds", + icon: Type, + category: "wordmark", + preset: true + }, + "wordmark-dark": { + id: "wordmark-dark", + label: "Wordmark Dark", + description: "Wordmark optimized for dark backgrounds", + icon: Type, + category: "wordmark", + preset: true + } +} + +/** + * Get variant definition for a variant name + * Returns preset definition if exists, or generates a default one for custom variants + */ +export function getVariantDefinition(variantName: string): VariantDefinition { + if (PRESET_VARIANTS[variantName]) { + return PRESET_VARIANTS[variantName] + } + + // Generate default definition for custom variants + const isWordmark = variantName.startsWith("wordmark-") + const category = isWordmark ? "wordmark" : "variant" + const displayName = variantName + .replace(/^wordmark-/, "") + .split("-") + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + + return { + id: variantName, + label: isWordmark ? `Wordmark ${displayName}` : displayName, + description: `Custom ${category} variant: ${variantName}`, + icon: isWordmark ? Type : FileImage, + category, + preset: false + } +} + +/** + * Check if a variant is a preset variant + */ +export function isPresetVariant(variantName: string): boolean { + return variantName in PRESET_VARIANTS +} + +/** + * Get all variants grouped by category + */ +export function groupVariantsByCategory(variants: string[]): { + variants: string[] + wordmark: string[] +} { + const result = { variants: [] as string[], wordmark: [] as string[] } + + variants.forEach(variant => { + if (variant.startsWith("wordmark-")) { + result.wordmark.push(variant) + } else { + result.variants.push(variant) + } + }) + + return result +} +``` + +### Implementation Details + +#### 1. Update Submission Form + +The submission form needs to track which variants are selected and store them as an array. + +**Current Code** ([`web/src/components/advanced-icon-submission-form-tanstack.tsx`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/components/advanced-icon-submission-form-tanstack.tsx#L149-226)): + +The form currently builds nested objects. We need to change it to build a simple array. + +**New Code**: +```typescript +const handleConfirmedSubmit = async () => { + const value = form.state.values + setShowConfirmDialog(false) + + try { + const assetFiles: File[] = [] + const variantNames: string[] = [] + + // Base icon is always first + if (value.files.base?.[0]) { + assetFiles.push(value.files.base[0]) + } + + // Track variant order: must match asset order + // Order: light, dark, then wordmark variants + if (value.files.light?.[0]) { + variantNames.push("light") + assetFiles.push(value.files.light[0]) + } + + if (value.files.dark?.[0]) { + variantNames.push("dark") + assetFiles.push(value.files.dark[0]) + } + + // Add custom variants in order they were added + Object.keys(value.files).forEach(variantId => { + if (variantId !== "base" && variantId !== "light" && variantId !== "dark" && + variantId !== "wordmark" && variantId !== "wordmark_dark") { + // Custom variant + if (value.files[variantId]?.[0]) { + variantNames.push(variantId) + assetFiles.push(value.files[variantId][0]) + } + } + }) + + // Wordmark variants + if (value.files.wordmark?.[0]) { + variantNames.push("wordmark-light") + assetFiles.push(value.files.wordmark[0]) + } + + if (value.files.wordmark_dark?.[0]) { + variantNames.push("wordmark-dark") + assetFiles.push(value.files.wordmark_dark[0]) + } + + // Build extras with simple variants array + const extras = { + aliases: value.aliases, + categories: value.categories, + base: value.files.base[0]?.name.split(".").pop() || "svg", + variants: variantNames // Simple array of variant names + } + + const submissionData = { + name: value.iconName, + assets: assetFiles, + created_by: (pb.authStore.record as any)?.id ?? pb.authStore.record?.id, + status: "pending", + description: value.description, + extras: extras, + } + + const record = await pb.collection("submissions").create(submissionData) + + // No need to update extras after upload - variants array is already correct! + // The order of variants matches the order of assets (base at 0, variants start at 1) + + // Revalidate Next.js cache + await revalidateAllSubmissions() + // ... rest of submission logic + } +} +``` + +#### 2. Update Community Library + +The `transformGalleryToIcon` function needs to convert the variants array to the display format. + +**Current Code** ([`web/src/lib/community.ts`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/lib/community.ts#L60-114)): + +**New Code**: +```typescript +function transformGalleryToIcon(item: CommunityGallery): any { + const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090" + + const mainIcon = item.assets?.[0] ? `${pbUrl}/api/files/community_gallery/${item.id}/${item.assets[0]}` : "" + const mainAssetExt = item.assets?.[0]?.split(".").pop()?.toLowerCase() || "svg" + const baseFormat = mainAssetExt === "svg" ? "svg" : mainAssetExt === "png" ? "png" : "webp" + + // Convert variants array to display format + // variants array: ["light", "dark", "wordmark-light"] + // assets array: [base, light_file, dark_file, wordmark_light_file] + // So variants[i] corresponds to assets[i+1] + + const variantsArray = item.extras?.variants || [] + const variants: Record = {} + const wordmark: Record = {} + + variantsArray.forEach((variantName: string, index: number) => { + const assetIndex = index + 1 // +1 because base is at index 0 + if (item.assets && assetIndex < item.assets.length) { + const assetFilename = item.assets[assetIndex] + + if (variantName.startsWith("wordmark-")) { + const wordmarkName = variantName.replace("wordmark-", "") + wordmark[wordmarkName] = assetFilename + } else { + variants[variantName] = assetFilename + } + } + }) + + // Migration: Support old format (colors/wordmark objects) + if (variantsArray.length === 0) { + // Fall back to old format for backward compatibility + const colors = item.extras?.colors ? { ...item.extras.colors } : undefined + if (colors && item.assets) { + Object.keys(colors).forEach((key) => { + const k = key as keyof typeof colors + if (colors[k]) { + variants[k] = findBestMatchingAsset(colors[k]!, item.assets || []) + } + }) + } + + const oldWordmark = item.extras?.wordmark ? { ...item.extras.wordmark } : undefined + if (oldWordmark && item.assets) { + Object.keys(oldWordmark).forEach((key) => { + const k = key as keyof typeof wordmark + if (oldWordmark[k]) { + wordmark[k] = findBestMatchingAsset(oldWordmark[k]!, item.assets || []) + } + }) + } + } + + const transformed = { + name: item.name, + status: item.status, + data: { + base: mainIcon || "svg", + baseFormat, + mainIconUrl: mainIcon, + assetUrls: item.assets?.map((asset) => `${pbUrl}/api/files/community_gallery/${item.id}/${asset}`) || [], + aliases: item.extras?.aliases || [], + categories: item.extras?.categories || [], + update: { + timestamp: item.created, + author: { + id: 0, + name: item.created_by || "Community", + login: item.created_by || undefined, + github_id: item.created_by_github_id, + }, + }, + variants: Object.keys(variants).length > 0 ? variants : undefined, + wordmark: Object.keys(wordmark).length > 0 ? wordmark : undefined, + // Keep old format for backward compatibility + colors: Object.keys(variants).length > 0 ? variants : undefined, + }, + } + + return transformed +} +``` + +#### 3. Update Display Components + +The `IconDetails` component needs to use the variant definitions file to render both preset and custom variants. + +**Current Code** ([`web/src/components/icon-details.tsx`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/components/icon-details.tsx#L713-755)): + +**New Code**: +```typescript +import { getVariantDefinition, groupVariantsByCategory } from "@/lib/variant-definitions" + +// In IconDetails component: +const normalizedVariants = iconData.variants || iconData.colors || {} +const normalizedWordmark = iconData.wordmark || {} + +// Get all variant names +const allVariants = Object.keys(normalizedVariants) +const allWordmarkVariants = Object.keys(normalizedWordmark) + +// Group and sort: preset variants first, then custom variants +const sortedVariants = allVariants.sort((a, b) => { + const aDef = getVariantDefinition(a) + const bDef = getVariantDefinition(b) + if (aDef.preset && !bDef.preset) return -1 + if (!aDef.preset && bDef.preset) return 1 + return a.localeCompare(b) +}) + +const sortedWordmarkVariants = allWordmarkVariants.sort((a, b) => { + const aDef = getVariantDefinition(`wordmark-${a}`) + const bDef = getVariantDefinition(`wordmark-${b}`) + if (aDef.preset && !bDef.preset) return -1 + if (!aDef.preset && bDef.preset) return 1 + return a.localeCompare(b) +}) + +// Render all variants dynamically +{sortedVariants.map((variantName) => { + const variantDef = getVariantDefinition(variantName) + const variantFilename = normalizedVariants[variantName] + + return ( + } + availableFormats={availableFormats} + icon={variantFilename || icon} + iconData={iconData} + handleCopy={handleCopyUrl} + handleDownload={handleDownload} + copiedVariants={copiedVariants} + renderVariant={renderVariant} + /> + ) +})} + +// Similar for wordmark variants... +``` + +#### 4. Update Type Definitions + +**Update** [`web/src/lib/pb.ts`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/lib/pb.ts#L25-L37): + +```typescript +extras: { + aliases: string[] + categories: string[] + base?: string + // New format: simple array of variant names + variants?: string[] // e.g., ["light", "dark", "wordmark-light", "monochrome"] + // Keep old format for backward compatibility during migration + colors?: { + dark?: string + light?: string + } + wordmark?: { + dark?: string + light?: string + } +} +``` + +#### 5. Update Import Script + +The import script needs to convert the variants array to metadata format. + +**Update** [`scripts/import-icon.ts`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/scripts/import-icon.ts): + +```typescript +function buildTargets(submission: Submission): VariantTarget[] { + const iconId = submission.name + const ext = inferBase(submission.assets, submission.extras?.base) + + const targets: VariantTarget[] = [ + { key: "base", destFilename: `${iconId}.${ext}` }, + ] + + // Handle new format: variants array + const variantsArray = submission.extras?.variants || [] + + variantsArray.forEach((variantName: string, index: number) => { + const assetIndex = index + 1 // +1 because base is at index 0 + if (assetIndex < submission.assets.length) { + const assetFilename = submission.assets[assetIndex] + + targets.push({ + key: variantName, + destFilename: `${iconId}-${variantName}.${ext}`, + exactFilename: assetFilename, + }) + } + }) + + // Migration: Support old format + if (variantsArray.length === 0) { + // Fall back to old colors/wordmark format + if (submission.extras?.colors?.light) { + targets.push({ + key: "light", + destFilename: `${iconId}-light.${ext}`, + exactFilename: submission.extras.colors.light, + }) + } + // ... similar for dark, wordmark variants + } + + return targets +} + +function buildMetadataVariants(assignments: VariantTarget[]): { + variants?: IconVariants + wordmark?: IconVariants +} { + const variants: IconVariants = {} + const wordmark: IconVariants = {} + + for (const v of assignments) { + if (!v.sourceAsset || v.key === "base") continue + + const baseName = v.destFilename.replace(/\.[^.]+$/, "") + + if (v.key.startsWith("wordmark-")) { + const wordmarkName = v.key.replace("wordmark-", "") + wordmark[wordmarkName] = baseName + } else { + variants[v.key] = baseName + } + } + + return { + variants: Object.keys(variants).length > 0 ? variants : undefined, + wordmark: Object.keys(wordmark).length > 0 ? wordmark : undefined, + } +} +``` + +### Benefits of This Approach + +1. **Simplicity**: Just an array of strings - no complex nested structures +2. **Flexibility**: Can store any variant name, preset or custom +3. **Order Preservation**: Array order directly maps to asset order +4. **Type Safety**: Variant definitions file provides type safety and metadata +5. **Separation of Concerns**: Variant metadata (labels, icons) separate from data storage +6. **Easy Migration**: Old format can be converted to array format easily +7. **No Filename Matching**: Direct index mapping eliminates fragile matching + +### Migration Strategy + +1. **Dual Format Support**: Code handles both old format (colors/wordmark objects) and new format (variants array) +2. **Migration on Read**: Convert old format to new format when reading from database +3. **Migration Script**: Optional batch script to convert existing submissions: + ```typescript + // For each submission: + // 1. Read extras.colors and extras.wordmark (old format) + // 2. Build variants array: ["light", "dark", "wordmark-light", "wordmark-dark"] + // 3. Update extras.variants = [...] + ``` +4. **New Submissions**: Always use new format (variants array) + +## References + +- Type definitions: [`web/src/types/icons.ts`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/types/icons.ts) +- PocketBase types: [`web/src/lib/pb.ts`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/lib/pb.ts#L14-L42) +- Variant definitions: `web/src/lib/variant-definitions.ts` (new file to create) +- Icon display component: [`web/src/components/icon-details.tsx`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/components/icon-details.tsx) +- Submission form: [`web/src/components/advanced-icon-submission-form-tanstack.tsx`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/components/advanced-icon-submission-form-tanstack.tsx) +- Filename matching logic: [`web/src/lib/community.ts`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/lib/community.ts#L24-L53) +- Import script: [`scripts/import-icon.ts`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/scripts/import-icon.ts) +- Community library: [`web/src/lib/community.ts`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/lib/community.ts) +- API library: [`web/src/lib/api.ts`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/lib/api.ts) +- Metadata pages: + - [`web/src/app/icons/[icon]/page.tsx`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/app/icons/[icon]/page.tsx) + - [`web/src/app/community/[icon]/page.tsx`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/app/community/[icon]/page.tsx) +- Customizer components: + - [`web/src/components/icon-customizer-inline.tsx`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/components/icon-customizer-inline.tsx) + - [`web/src/components/icon-customizer.tsx`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/src/components/icon-customizer.tsx) +- PocketBase migration: [`web/backend/pb_migrations/1759312839_created_submission.js`](https://github.com/homarr-labs/dashboard-icons/blob/2aefb5cde0b8f9a4ee54b430218dd718a15efa0a/web/backend/pb_migrations/1759312839_created_submission.js)