Compare commits

...

97 Commits

Author SHA1 Message Date
Thomas Camlong
8c87e66918 refactor: migrate to TanStack Form and fix IconNameCombobox integration
- Remove old advanced-icon-submission-form.tsx (replaced by TanStack version)
- Fix TanStack Form implementation:
  - Remove generic type argument and use type assertion instead
  - Fix form.Subscribe selector to return object instead of array
  - Remove unused IconCard import
- Update editable-icon-details.tsx to use new IconNameCombobox API:
  - Remove deprecated onIsExisting prop
  - Remove isExistingIcon state management
  - Simplify form submission messages

All components now use the updated IconNameCombobox with error/isInvalid props
instead of the old onIsExisting callback pattern.
2025-10-13 15:50:08 +02:00
Thomas Camlong
cd1a3fda59 feat: add comprehensive MultiSelect UI component
- Feature-rich multi-select dropdown with search and filtering
- Support for grouped options and custom icons
- Disabled option support (for required selections)
- Animations (bounce, pulse, wiggle, fade, slide)
- Responsive design with mobile/tablet/desktop configs
- Accessibility with ARIA labels and keyboard navigation
- Badge display with customizable max count
- Single-line or wrapped badge layout options
- Imperative API via ref (reset, clear, setValues, focus)
- Form integration with controlled/uncontrolled modes
- Auto-dedupe options and comprehensive error handling
2025-10-13 15:39:54 +02:00
Thomas Camlong
888d1f26ac deps: add TanStack Form and Radix UI packages
- Add @tanstack/react-form@1.23.6 for advanced form management
- Add radix-ui@1.4.3 meta-package for comprehensive UI components
- Includes form validation, state management, and component primitives
- Required dependencies: @tanstack/store, decode-formdata, devalue
2025-10-13 15:39:39 +02:00
Thomas Camlong
0fd6db891f feat: add file upload dropzone to icon submission dialog
- Add Dropzone component for direct file uploads
- Support multiple image formats (png, jpg, svg, webp)
- Show file preview after upload
- Add divider between dropzone and GitHub issue templates
- Max 5 files with 5MB size limit each
2025-10-13 15:39:25 +02:00
Thomas Camlong
7dc93ac86f style: set card background to pure white for better contrast
- Change --card from slightly off-white to pure white (oklch 1.0000)
2025-10-13 15:39:03 +02:00
Thomas Camlong
a9a97f54b5 refactor: remove icon submission form from 404 page
- Clean up 404 page by removing submission section
- Keep focus on error state and navigation back home
2025-10-13 15:38:50 +02:00
Thomas Camlong
676ee079d6 style: reduce CommandInput padding for compact appearance
- Change py-3 to py-1 for less vertical padding
- Remove h-10 fixed height for better flexibility
2025-10-13 15:38:37 +02:00
Thomas Camlong
e4fa1a4d31 chore: simplify command menu search placeholder text
- Remove "or category" from placeholder since search is name-based only
2025-10-13 15:38:24 +02:00
Thomas Camlong
baa85d4b79 feat: merge metadata.json icons with database icons for validation
- Fetch icons from both community_gallery database AND metadata.json
- Combine both sources to get complete list of existing icons
- Prevent submission of icons that exist in either source
- Ensure comprehensive validation against all known icons
2025-10-13 15:38:12 +02:00
Thomas Camlong
555898fa69 feat: integrate MultiSelect for variant selection and improve form UX
- Replace manual variant cards with MultiSelect component
- Add VARIANT_OPTIONS with FileImage/FileType icons
- Make base variant disabled (always required, cannot be removed)
- Show upload zones only for selected variants (reactive with field.state.value)
- Move remove button to top-right corner as small icon-only button
- Add icon preview section with proper object-contain styling
- Use form.Subscribe for reactive preview updates
- Validate icon names against existing icons from database
- Show clear error message when icon already exists
- Remove isExistingIcon field (updates not yet supported)
- Improve preview image display with centered flex layout
- Add variant labels below preview images
- Consolidate form into single Card component
- Fix image cropping issues with object-contain instead of object-cover
2025-10-13 15:37:59 +02:00
Thomas Camlong
7fe7d43c1a refactor: redesign icon name input with inline suggestions
- Replace popover-based combobox with direct input field
- Add inline dropdown showing existing icons (max 50 for performance)
- Implement real-time search filtering on both value and label
- Track raw input separately for instant search feedback
- Display existing icons as warnings with AlertCircle icons
- Add proper validation error display with TanStack Form integration
- Show validation errors in red below input
- Add aria-invalid and aria-describedby for accessibility
- Sync raw input with sanitized value on blur
- Prevent selecting existing icons (shows as not allowed)
2025-10-13 15:37:41 +02:00
Thomas Camlong
758c4a5bbc feat: add file preview functionality to dropzone
- Add filePreviews state to track base64 preview URLs
- Implement FileReader-based preview generation on file drop
- Add preview image display in DropzoneContent
- Update variant removal to clear associated previews
- Add preview cleanup in form reset and clear functions
- Enhance user experience with immediate visual feedback
2025-10-02 16:20:56 +02:00
Thomas Camlong
c5949aab03 feat: implement immediate PostHog user identification on auth
- Add immediate user identification after successful login/registration
- Implement PostHog event tracking for user_registered and user_logged_in
- Follow PostHog best practice of calling identify as soon as possible
- Use centralized PostHog utility functions for consistency
- Add comprehensive user properties to PostHog person profiles
2025-10-02 16:20:49 +02:00
Thomas Camlong
ef8bc885d2 feat: enhance logout with PostHog event tracking and reset
- Add PostHog event capture for user_logged_out
- Implement proper PostHog identity reset on logout
- Add user data tracking before clearing auth
- Follow PostHog best practices for logout handling
- Import and use centralized PostHog utility functions
2025-10-02 16:20:43 +02:00
Thomas Camlong
d28b495421 feat: migrate submit page to TanStack React Form
- Replace AdvancedIconSubmissionForm with AdvancedIconSubmissionFormTanStack
- Update import to use new TanStack-based form component
- Maintain all existing functionality and UI
2025-10-02 16:20:38 +02:00
Thomas Camlong
fe9f5edc9a feat: integrate PostHog authentication handler
- Add PostHogAuthHandler component to PostHogProvider
- Integrate usePostHogAuth hook for automatic user identification
- Add person_profiles: 'identified_only' configuration
- Enable automatic user identification on app load and auth changes
2025-10-02 16:20:32 +02:00
Thomas Camlong
08ff932257 feat: implement PostHog authentication hook
- Create usePostHogAuth hook for automatic user identification
- Add session-based identification tracking to prevent duplicate calls
- Implement proper cleanup of PocketBase auth listeners
- Follow PostHog best practices for identify timing
- Integrate with centralized PostHog utility functions
2025-10-02 16:20:25 +02:00
Thomas Camlong
a2fbc03bd6 feat: add PostHog utility functions for user identification
- Create centralized identifyUserInPostHog function
- Add resetPostHogIdentity function for logout handling
- Implement comprehensive person properties mapping
- Follow PostHog best practices for user identification
- Centralize PostHog logic to avoid code duplication
2025-10-02 16:20:19 +02:00
Thomas Camlong
1d0f264dda feat: implement TanStack React Form for icon submission
- Create new AdvancedIconSubmissionFormTanStack component
- Replace useState form management with useForm hook
- Add comprehensive field-level validation
- Implement type-safe form data structure
- Add real-time validation with immediate feedback
- Maintain all existing functionality (file uploads, previews, variants)
- Improve performance with optimized re-renders
2025-10-02 16:20:10 +02:00
Thomas Camlong
6cb2f39a1d feat: add @tanstack/react-form dependency
- Add @tanstack/react-form v1.11.0 to package.json
- Enables modern form state management with validation
- Supports type-safe form handling and field-level validation
2025-10-02 16:20:03 +02:00
Thomas Camlong
391a69f82e refactor(ui): update LoginModal usage 2025-10-02 15:23:56 +02:00
Thomas Camlong
e31b97f60e feat(hooks): add useExistingIconNames hook and clean up debug logs
- Add new useExistingIconNames hook for fetching community gallery icon names
- Implement proper caching with 5-minute stale time
- Remove debug console.log statements from useSubmissions hook
- Improve code organization and reusability
- Add proper error handling and retry configuration
2025-10-02 15:20:47 +02:00
Thomas Camlong
2ef6e4162c feat(ui): enhance dialog component with improved styling and optional close button
- Add showCloseButton prop to DialogContent for conditional close button display
- Improve overlay styling with better backdrop blur and opacity
- Add explicit background color to dialog content
- Reorganize imports for better code organization
- Enhance accessibility with proper data-slot attributes
- Improve visual consistency and user experience
2025-10-02 15:20:40 +02:00
Thomas Camlong
8a4c92930d refactor(debug): remove console logs from submissions data table
- Clean up debug console.log statements from getDisplayName function
- Remove logging for submission data, user data, and fallback values
- Improve code cleanliness and production readiness
- Maintain functionality while reducing console noise
2025-10-02 15:20:34 +02:00
Thomas Camlong
23e0ea54ff fix(api): correct PocketBase baseURL property name
- Fix typo from baseUrl to baseURL in submission details
- Ensure proper API endpoint construction for asset URLs
- Improve consistency with PocketBase SDK naming conventions
2025-10-02 15:20:27 +02:00
Thomas Camlong
e9e9aefb79 feat(ui): add theme-aware gradient colors to magic card
- Integrate next-themes for dynamic color switching
- Add light/dark theme specific gradient colors
- Implement state management for theme-based color updates
- Use pink gradient for dark theme and blue gradient for light theme
- Improve visual consistency across different theme modes
2025-10-02 15:20:22 +02:00
Thomas Camlong
6fc0a06fc4 feat(ui): major login modal redesign with improved UX
- Complete UI/UX overhaul with modern design and better spacing
- Add form reset functionality and improved state management
- Replace custom loading spinner with Lucide Loader2 icon
- Remove BorderBeam effects for cleaner appearance
- Improve error handling and validation flow
- Enhance accessibility with better focus management
- Update button styling and layout for better visual hierarchy
- Add loading states and improve user feedback
- Refactor form structure for better maintainability
2025-10-02 15:20:16 +02:00
Thomas Camlong
f0215627d7 refactor(ui): improve icon name combobox with custom hook and better UX
- Replace direct PocketBase calls with useExistingIconNames hook
- Add loading states and better error handling
- Improve create new icon flow with preview functionality
- Extract icon name sanitization logic into reusable function
- Enhance empty state with loading indicator
- Reorganize component structure for better maintainability
2025-10-02 15:20:11 +02:00
Thomas Camlong
f4819acc7c style: remove unnecessary empty line in icon-card
- Clean up code formatting by removing extra blank line
- Improve code consistency and readability
2025-10-02 15:20:05 +02:00
Thomas Camlong
c471b87436 refactor(ui): update submit button text to singular form
- Change "Submit icon(s)" to "Submit icon" for consistency
- Update both authenticated and unauthenticated button states
- Improve text clarity and user experience
2025-10-02 15:20:01 +02:00
Thomas Camlong
9f4a1d9387 refactor(ui): simplify new icon submission alert
- Remove Check icon from new icon submission alert
- Simplify alert text by removing bold formatting
- Improve visual consistency with cleaner design
2025-10-02 15:19:56 +02:00
Thomas Camlong
d542377d97 feat: improve theme and usability 2025-10-02 12:30:22 +02:00
Thomas Camlong
680246d50e refactor: update recently added icons to use primary theme colors
- Replace rose color references with primary theme colors
- Update gradient text and hover effects to use primary color
- Improve theme consistency across recently added icons section
- Enhance maintainability with centralized color management
2025-10-02 12:09:26 +02:00
Thomas Camlong
9918c5507e refactor: replace hardcoded rose colors with primary theme colors
- Replace rose-500 color references with primary theme colors
- Update gradient backgrounds to use primary color variables
- Simplify shadow classes using CSS custom properties
- Ensure consistent theming across hero section components
- Improve maintainability and theme consistency
2025-10-02 12:09:18 +02:00
Thomas Camlong
d2a94382da proposition of completely new theme 2025-10-02 11:44:26 +02:00
Thomas Camlong
13a1192dc2 feat: add advanced dropzone component
- Create comprehensive file dropzone component using react-dropzone
- Support file type validation, size limits, and file count restrictions
- Add drag-and-drop functionality with visual feedback
- Implement file preview and replacement capabilities
- Include context-based state management for file handling
- Support custom empty states and content rendering
- Add file size formatting and validation error handling
2025-10-02 10:52:24 +02:00
Thomas Camlong
4e58f705d3 feat: add advanced combobox component
- Create comprehensive combobox component with context management
- Support controllable state with @radix-ui/react-use-controllable-state
- Add search functionality with Command component integration
- Implement create new item functionality
- Include responsive width detection with ResizeObserver
- Support grouped items, empty states, and custom triggers
- Provide flexible data structure for various use cases
2025-10-02 10:52:18 +02:00
Thomas Camlong
e1ae75d27f feat: add BorderBeam animated component
- Create animated border beam component using Framer Motion
- Support customizable size, duration, delay, and colors
- Add reverse animation and initial offset options
- Implement gradient color transitions
- Provide configurable border width and styling
- Enable smooth circular border animations
2025-10-02 10:52:13 +02:00
Thomas Camlong
0a9d700144 feat: add icon name combobox component
- Create combobox for icon name selection and creation
- Integrate with PocketBase to fetch existing icon names
- Add input sanitization for icon IDs (lowercase, hyphens only)
- Implement existing icon detection and validation
- Support both selection from existing icons and creation of new ones
- Provide visual feedback for new vs existing icon submissions
2025-10-02 10:52:06 +02:00
Thomas Camlong
9cb8e220cb feat: implement advanced icon submission form
- Create comprehensive icon submission form with multiple variants
- Add support for base, dark, light, wordmark, and wordmark_dark variants
- Implement file upload with drag-and-drop functionality
- Add icon name validation and existing icon detection
- Include category selection, aliases, and description fields
- Integrate with PocketBase for submission storage
- Add form validation and error handling
2025-10-02 10:52:00 +02:00
Thomas Camlong
171e897280 feat: create submit page with authentication flow
- Add submit page with authentication check
- Implement login modal integration for unauthenticated users
- Create submission guidelines and requirements display
- Add AdvancedIconSubmissionForm component integration
- Handle loading states and user experience flow
2025-10-02 10:51:54 +02:00
Thomas Camlong
ec9453aa4f feat: add background wrapper layout components
- Create BackgroundWrapper component for community, dashboard, and submit pages
- Add grid pattern background with dark mode support
- Implement radial gradient mask for visual depth
- Provide consistent layout structure across app sections
2025-10-02 10:51:48 +02:00
Thomas Camlong
dd4bd2e565 feat: enhance login modal with animated border effects
- Add BorderBeam component import and implementation
- Create animated red and blue border effects
- Add relative overflow-hidden positioning
- Improve visual appeal and user engagement
2025-10-02 10:51:32 +02:00
Thomas Camlong
c210b4a8c5 feat: add onClick prop to IconSubmissionForm
- Add optional onClick prop to IconSubmissionForm component
- Enable external click handling for form triggers
- Improve component flexibility and reusability
2025-10-02 10:51:27 +02:00
Thomas Camlong
f45fa072af style: remove extra padding from search results header
- Remove pb-2 class from results header div
- Align with community-icon-search styling
- Improve visual consistency across search components
2025-10-02 10:51:21 +02:00
Thomas Camlong
310190f6c1 refactor: simplify header submit button implementation
- Remove IconSubmissionForm component usage
- Replace with direct Link navigation to /submit
- Simplify mobile and desktop submit button logic
- Remove unused import and improve code clarity
2025-10-02 10:51:17 +02:00
Thomas Camlong
22ac70dc9f style: remove extra padding from results header
- Remove pb-2 class from results header div
- Improve visual spacing consistency
- Clean up layout spacing issues
2025-10-02 10:51:12 +02:00
Thomas Camlong
3aa0c84f75 style: adjust border radius for more subtle design
- Reduce --radius from 0.4rem to 0.2rem
- Create more refined visual appearance
- Maintain design consistency across components
2025-10-02 10:51:08 +02:00
Thomas Camlong
95e9497c2e chore: update lockfile for new dependencies
- Update pnpm-lock.yaml with new package versions
- Include attr-accept and file-selector dependencies
- Ensure consistent dependency resolution
2025-10-02 10:51:04 +02:00
Thomas Camlong
c0944d5423 feat: add new dependencies for enhanced functionality
- Add @radix-ui/react-use-controllable-state for state management
- Add react-dropzone for file upload capabilities
- Enable advanced form interactions and file handling
2025-10-02 10:50:59 +02:00
Thomas Camlong
f1b198a6d4 feat: add MagicUI registry configuration
- Add @magicui registry to components.json
- Reorder properties for better organization
- Enable MagicUI component integration
2025-10-02 10:50:55 +02:00
Thomas Camlong
a369676609 feat(web): enhance community page 2025-10-01 19:08:16 +02:00
Thomas Camlong
49aab75953 format code + change env 2025-10-01 19:01:31 +02:00
Thomas Camlong
0a4a4a78f4 feat(web): add community icons browse page
Add new /community page to browse and search community-submitted icons. Implements server-side data fetching with 10-minute revalidation, SEO optimization with dynamic metadata generation, and integration with CommunityIconSearch component for rich filtering and search capabilities
2025-10-01 18:23:22 +02:00
Thomas Camlong
07f196f12f feat(web): add community icon search component
Add comprehensive search and filter component for community-submitted icons. Features include real-time search with debouncing, category filtering, multiple sort options (relevance, A-Z, Z-A, newest), and grouped display by submission status. Integrates with URL query parameters for shareable filtered views
2025-10-01 18:23:19 +02:00
Thomas Camlong
63507a767a feat(web): add login and registration modal component
Add comprehensive LoginModal component with toggle between login and registration modes. Includes form validation, error handling, loading states, and placeholder for GitHub OAuth (coming soon). Provides accessible form with proper ARIA attributes and keyboard navigation
2025-10-01 18:23:15 +02:00
Thomas Camlong
d221fb5c79 feat(web): add submission server actions for cache management
Add server actions that can be called from client components to trigger cache revalidation after submission status changes. Provides revalidateCommunitySubmissions for community page updates and revalidateAllSubmissions for dashboard and community pages
2025-10-01 18:23:12 +02:00
Thomas Camlong
c0851a73c7 feat(web): add community gallery server utilities
Add server-side utilities for fetching and transforming community gallery data from PocketBase. Includes cached and non-cached versions of fetch functions with 10-minute revalidation. Transforms CommunityGallery records to IconWithName format for use with existing icon components
2025-10-01 18:23:09 +02:00
Thomas Camlong
77e55e750f feat(web): add cache revalidation utilities
Add server-side utilities for cache revalidation using Next.js revalidatePath and revalidateTag. Provides functions to revalidate community page and all submission-related pages after data changes
2025-10-01 18:23:05 +02:00
Thomas Camlong
b277ceb9a0 feat(web): add database seeding script for testing
Add seed-db.ts script to populate PocketBase database with fake submission data. Creates test users, downloads icon images from CDN, and generates random submissions with various statuses (pending, approved, rejected, added_to_collection) for development and testing purposes
2025-10-01 18:23:02 +02:00
Thomas Camlong
7653ee6e17 feat(web): add CommunityGallery interface and collection type
Add CommunityGallery interface for the community_gallery collection view. Add "added_to_collection" status to Submission type. Extend TypedPocketBase interface to include strongly-typed community_gallery collection
2025-10-01 18:22:53 +02:00
Thomas Camlong
fd8b50776a style(web): improve dialog overlay backdrop blur
Reduce dialog overlay backdrop blur from default to 2px and decrease opacity from 50% to 30% for a more subtle, modern appearance that better matches the application's design system
2025-10-01 18:22:50 +02:00
Thomas Camlong
5f5e3ef825 feat(web): add "added_to_collection" status to submissions
Add support for new submission status "added_to_collection" indicating icons that have been officially added to the main collection. Update status priority ordering, color scheme (green badge), and display name formatting
2025-10-01 18:22:48 +02:00
Thomas Camlong
bf93408568 refactor(web): extract grid classes and add documentation
Extract shared grid classes into GRID_CLASSES constant for reusability. Add JSDoc comments to explain the purpose of IconsGrid (non-virtualized for small lists) and VirtualizedIconsGrid (optimized for large datasets) components
2025-10-01 18:22:44 +02:00
Thomas Camlong
db893d4f97 feat(web): add support for community icons in icon card
Update IconCard component to handle both regular icons and community-submitted icons. Community icons use HTTP URLs as image source and link to /community/:name instead of /icons/:name for proper routing
2025-10-01 18:22:42 +02:00
Thomas Camlong
fc39fd12c9 feat(web): refactor header with login modal and github stars
Replace LoginPopup component with new LoginModal component for better UX. Add GitHub repository star count display. Implement authentication guards for submission button - shows login modal for unauthenticated users. Add user dropdown menu with dashboard link and sign out functionality
2025-10-01 18:22:39 +02:00
Thomas Camlong
c11bcfa179 feat(web): add community and dashboard links to navigation
Add navigation links for community page and dashboard page (shown only when logged in). Implements active state highlighting for all navigation items including the new community and dashboard pages
2025-10-01 18:22:35 +02:00
Thomas Camlong
ddf1f13d7a style(web): add enhanced input styling for better UX
Add comprehensive input styling for text, email, password, and search inputs with focus states and error state handling. Improves visual feedback and accessibility for form inputs
2025-10-01 18:22:33 +02:00
Thomas Camlong
da40db6183 build(web): add seed script for database population
Add npm script 'seed' that runs the seed-db.ts file using bun runtime to populate the PocketBase database with fake submission data for testing and development purposes
2025-10-01 18:22:30 +02:00
Thomas Camlong
3d0eab4f01 chore(web): remove outdated migration files 2025-10-01 15:52:34 +02:00
Thomas Camlong
3f4052080c chore(web): remove unused database files 2025-10-01 15:51:55 +02:00
Thomas Camlong
c02e773be4 docs(web): remove SEO audit documentation file 2025-10-01 15:47:42 +02:00
Thomas Camlong
1b2837ac5a feat(web): add admin dashboard page for managing icon submissions 2025-10-01 15:47:26 +02:00
Thomas Camlong
fb99a7ff9a feat(web): add submissions data table with filtering, sorting, and pagination 2025-10-01 15:47:25 +02:00
Thomas Camlong
be90e727c1 feat(web): add submission details dialog component with review and approval functionality 2025-10-01 15:47:23 +02:00
Thomas Camlong
f600ba5abb feat(web): add user display component with avatar and username 2025-10-01 15:47:22 +02:00
Thomas Camlong
36d4128e96 feat(web): add user authentication button with login and profile popover 2025-10-01 15:47:21 +02:00
Thomas Camlong
3b6a8ad39f feat(web): add custom hook for managing icon submissions with React Query 2025-10-01 15:47:19 +02:00
Thomas Camlong
9e2aeea596 feat(web): add React Query provider wrapper component 2025-10-01 15:47:18 +02:00
Thomas Camlong
5194a53fda feat(web): add PocketBase client initialization and type definitions 2025-10-01 15:47:16 +02:00
Thomas Camlong
b8920b912a feat(web): add PocketBase backend with migrations and database 2025-10-01 15:47:15 +02:00
Thomas Camlong
a93034d5b5 chore: add root pnpm lockfile for workspace 2025-10-01 15:47:14 +02:00
Thomas Camlong
a69830c98d chore: add root package.json for monorepo workspace configuration 2025-10-01 15:47:12 +02:00
Thomas Camlong
a1bbfd6d23 chore: add VS Code workspace configuration file 2025-10-01 15:47:11 +02:00
Thomas Camlong
c56586f5ba feat(web): integrate PocketBase authentication with LoginPopup in header 2025-10-01 15:47:02 +02:00
Thomas Camlong
e10008ece5 refactor(web): move development environment check before useEffect hook in Carbon component 2025-10-01 15:47:01 +02:00
Thomas Camlong
69d0b1f2e5 feat(web): wrap app with react-query Providers in root layout 2025-10-01 15:46:59 +02:00
Thomas Camlong
779a7a48ab chore(web): update lockfile for new dependencies 2025-10-01 15:46:57 +02:00
Thomas Camlong
579ad9d6eb feat(web): add pocketbase, tanstack query, and turbo dependencies with backend scripts 2025-10-01 15:46:56 +02:00
Thomas Camlong
763a204c8e chore(web): disable useUniqueElementIds linter rule in biome config 2025-10-01 15:46:55 +02:00
Thomas Camlong
036effc872 docs(web): remove SEO audit documentation file 2025-10-01 15:46:53 +02:00
Thomas Camlong
eedded4e47 chore(web): add backend and turborepo to gitignore 2025-10-01 15:46:51 +02:00
Thomas Camlong
9cbcb67feb feat (web): Make buttons always cursor-pointer 2025-10-01 15:45:43 +02:00
Thomas Camlong
03649e45e1 testing ci 2025-10-01 10:02:14 +02:00
Thomas Camlong
73e787e5b8 feat (web): Turn into nextjs standalone output 2025-10-01 09:56:29 +02:00
Thomas Camlong
b5c72677fc chore: format codebase 2025-09-29 11:01:14 +02:00
Thomas Camlong
68970f5908 chore: update biome config + package dependencies
- Deleted the biome.json configuration file.
- Updated dependencies in package.json:
  - lucide-react from ^0.543.0 to ^0.544.0
  - next from 15.5.2 to 15.5.4
  - posthog-js from ^1.262.0 to ^1.268.7
  - posthog-node from ^5.8.2 to ^5.9.1
  - @biomejs/biome from 1.9.4 to 2.2.4
  - Updated package manager version from pnpm@10.15.1 to pnpm@10.17.1
2025-09-29 10:55:52 +02:00
72 changed files with 7560 additions and 1497 deletions

View File

@@ -0,0 +1,10 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"biome.configurationPath": "./web/biome.jsonc"
}
}

7
web/.gitignore vendored
View File

@@ -43,3 +43,10 @@ next-env.d.ts
# Cursor
.cursor
.env.local
# Turborepo
.turbo
# Pocketbase
backend/pocketbase
backend/pb_data
backend/pb_migrations

View File

@@ -1,916 +0,0 @@
# Dashboard Icons SEO Audit 2025
## Overview
This document presents a comprehensive SEO audit for the Dashboard Icons website built with Next.js 15.3. The audit analyzes current implementation and provides detailed recommendations based on the latest Next.js best practices for optimal search engine visibility and performance.
## Table of Contents
- [Current Implementation Assessment](#current-implementation-assessment)
- [Metadata Implementation](#metadata-implementation)
- [SEO Optimization Checklist](#seo-optimization-checklist)
- [Technical SEO](#technical-seo)
- [Performance Optimization](#performance-optimization)
- [Content and User Experience](#content-and-user-experience)
- [Mobile Optimization](#mobile-optimization)
- [Advanced Next.js 15.3 SEO Features](#advanced-nextjs-153-seo-features)
- [Recommendations](#recommendations)
- [Conclusion](#conclusion)
- [References](#references)
## Current Implementation Assessment
The Dashboard Icons project currently implements several good SEO practices:
- [x] Basic metadata configuration in layout.tsx and page.tsx files
- [x] Dynamic title and description generation with appropriate keyword inclusion
- [x] Open Graph tags for social sharing with proper image dimensions
- [x] Twitter Card metadata implementation for social visibility
- [x] Proper use of semantic HTML elements for content structure
- [x] Server-side rendering for improved indexing and crawler access
- [x] Canonical URLs properly configured across page types
- [x] Image optimization with next/image component for improved Core Web Vitals
However, there are several opportunities for improvement:
- [ ] No robots.txt implementation for directing crawler behavior
- [ ] Missing XML sitemap for improved content discovery
- [ ] No structured data (JSON-LD) for enhanced search results
- [ ] Limited use of advanced Next.js 15.3 metadata features
- [ ] Missing breadcrumb navigation for enhanced user experience and SEO
- [ ] No dynamic OG images for improved social sharing
## Metadata Implementation
The project uses Next.js App Router's built-in metadata API effectively across different page types:
### Root Layout Metadata Analysis
In `layout.tsx`, the site establishes global metadata that provides a solid foundation:
```typescript
// In layout.tsx
export async function generateMetadata(): Promise<Metadata> {
const { totalIcons } = await getTotalIcons()
return {
metadataBase: new URL(WEB_URL),
title: websiteTitle,
description: getDescription(totalIcons),
keywords: ["dashboard icons", "service icons", "application icons", "tool icons", "web dashboard", "app directory"],
robots: {
index: true,
follow: true,
googleBot: "index, follow",
},
openGraph: {
siteName: WEB_URL,
title: websiteTitle,
url: BASE_URL,
description: getDescription(totalIcons),
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "Dashboard Icons - Dashboard icons for self hosted services",
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
title: WEB_URL,
description: getDescription(totalIcons),
images: ["/og-image.png"],
},
applicationName: WEB_URL,
alternates: {
canonical: BASE_URL,
},
// Additional configurations...
}
}
```
**Strengths:**
- Properly sets metadataBase for all relative URLs
- Includes comprehensive metadata for SEO and social sharing
- Dynamically generates description based on content (totalIcons)
- Properly configures robots directives
**Areas for improvement:**
- The `websiteTitle` ("Free Dashboard Icons - Download High-Quality UI & App Icons") could be more specific
- The OpenGraph URL points to BASE_URL (CDN) rather than WEB_URL (the actual site)
- Twitter title uses WEB_URL instead of an actual title
- Missing locale information for international SEO
### Page-Specific Metadata Analysis
For individual icon pages, metadata is comprehensively generated based on icon data:
```typescript
// In [icon]/page.tsx
export async function generateMetadata({ params, searchParams }: Props, parent: ResolvingMetadata): Promise<Metadata> {
const { icon } = await params
const iconsData = await getAllIcons()
// ...processing code...
const formattedIconName = icon
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
return {
title: `${formattedIconName} Icon | Dashboard Icons`,
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE. Part of a collection of ${totalIcons} curated icons...`,
openGraph: {
title: `${formattedIconName} Icon | Dashboard Icons`,
description: `Download the ${formattedIconName} icon in SVG, PNG, and WEBP formats for FREE...`,
type: "article",
url: pageUrl,
authors: [authorName],
publishedTime: updateDate.toISOString(),
modifiedTime: updateDate.toISOString(),
section: "Icons",
tags: [formattedIconName, "dashboard icon", "service icon", ...],
},
twitter: {
card: "summary_large_image",
title: `${formattedIconName} Icon | Dashboard Icons`,
description: `Download the ${formattedIconName} icon...`,
images: [iconImageUrl],
},
alternates: {
canonical: pageUrl,
media: {
png: iconImageUrl,
svg: `${BASE_URL}/svg/${icon}.svg`,
webp: `${BASE_URL}/webp/${icon}.webp`,
},
},
}
}
```
**Strengths:**
- Excellent dynamic title generation with proper formatting
- Comprehensive description with icon-specific information
- Proper OpenGraph article configuration with author and timestamp data
- Well-structured alternates configuration for different media types
- Good keyword inclusion in meta tags
**Areas for improvement:**
- Could benefit from structured data for product/image entity
- Could implement dynamic OG images with the ImageResponse API
### Icons Browse Page Metadata Analysis
The icons browse page implements specific metadata optimized for its purpose:
```typescript
// In icons/page.tsx
export async function generateMetadata(): Promise<Metadata> {
const icons = await getIconsArray()
const totalIcons = icons.length
return {
title: "Browse Icons | Free Dashboard Icons",
description: `Search and browse through our collection of ${totalIcons} curated icons for services, applications and tools...`,
keywords: [
"browse icons",
"dashboard icons",
"icon search",
// ...
],
openGraph: {
title: "Browse Icons | Free Dashboard Icons",
description: `Search and browse through our collection of ${totalIcons} curated icons...`,
// ...
},
// Additional configurations...
}
}
```
**Strengths:**
- Clear, purpose-driven title
- Dynamic description that includes the collection size
- Relevant keywords for the browse page functionality
**Areas for improvement:**
- Could implement pagination metadata (prev/next) if applicable
- Missing structured data for collection/gallery
## SEO Optimization Checklist
### Metadata and Head Tags
- [x] Page titles are unique, descriptive, and include keywords
- [x] Meta descriptions are compelling and keyword-rich (under 160 characters)
- [x] Open Graph tags are implemented for social sharing
- [x] Twitter Card metadata is implemented
- [x] Canonical URLs are properly set
- [ ] Structured data/JSON-LD for rich snippets
- [x] Properly configured viewport meta tag
- [x] Favicon and apple-touch-icon are set
- [x] Keywords meta tag is implemented (though not as influential for rankings as before)
- [ ] Language and locale information (hreflang) for international SEO
### Indexation and Crawling
- [x] Server-side rendering for improved indexability
- [ ] robots.txt file implementation
- [ ] XML sitemap generation
- [x] Proper HTTP status codes (200, 404, etc.)
- [x] Internal linking structure
- [ ] Pagination handling with proper rel="next" and rel="prev" tags
- [ ] Implementation of dynamic sitemap with Next.js 15.3 file-based API
### Content Structure
- [x] Clean URL structure (`/icons/[icon]`)
- [x] Semantic HTML headings (h1, h2, etc.)
- [x] Content hierarchy matches visual hierarchy
- [ ] Breadcrumb navigation for improved user experience and crawlability
- [ ] Schema.org markup for content types
## Technical SEO
### Server-side Rendering and Static Generation
The project effectively uses Next.js App Router to implement:
- **Static Generation (SSG)** for homepage and catalog pages, providing fast initial load times and improved indexability
- **Server-Side Rendering (SSR)** for dynamic content, ensuring fresh content is always accessible to crawlers
- **Incremental Static Regeneration (ISR)** potential for optimal performance and content freshness
These approaches ensure search engines can properly crawl and index content while providing optimal performance.
### Dynamic Routes Implementation
Dynamic routes like `/icons/[icon]` are properly implemented with `generateStaticParams` to pre-render paths at build time:
```typescript
export async function generateStaticParams() {
const iconsData = await getAllIcons()
return Object.keys(iconsData).map((icon) => ({
icon,
}))
}
```
This approach ensures all icon pages are pre-rendered during build time, optimizing both performance and SEO by making all content immediately available to search engine crawlers without requiring JavaScript execution.
### Missing Critical Components
#### robots.txt Implementation
Currently missing a robots.txt file which is essential for directing search engine crawlers. Next.js 15.3 offers a file-based API that should be implemented:
```typescript
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: 'https://dashboardicons.com/sitemap.xml',
}
}
```
#### sitemap.xml Implementation
No sitemap implementation was found. A sitemap is critical for search engines to discover and index all pages efficiently. Next.js 15.3's file-based API makes this easy to implement:
```typescript
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllIcons } from '@/lib/api'
import { BASE_URL, WEB_URL } from '@/constants'
export default async function sitemap(): MetadataRoute.Sitemap {
const iconsData = await getAllIcons()
const lastModified = new Date()
// Base routes
const routes = [
{
url: WEB_URL,
lastModified,
changeFrequency: 'weekly',
priority: 1.0,
},
{
url: `${WEB_URL}/icons`,
lastModified,
changeFrequency: 'daily',
priority: 0.9,
},
// Other static routes
]
// Icon routes
const iconRoutes = Object.keys(iconsData).map((icon) => ({
url: `${WEB_URL}/icons/${icon}`,
lastModified: new Date(iconsData[icon].update.timestamp),
changeFrequency: 'weekly' as const,
priority: 0.7,
}))
return [...routes, ...iconRoutes]
}
```
For larger icon collections, Next.js 15.3 supports `generateSitemaps` for creating multiple sitemap files:
```typescript
// app/sitemap.ts
export async function generateSitemaps() {
const totalIcons = await getTotalIconCount()
// Google's limit is 50,000 URLs per sitemap
const sitemapsNeeded = Math.ceil(totalIcons / 50000)
return Array.from({ length: sitemapsNeeded }, (_, i) => ({ id: i }))
}
export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
// Fetch icons for this specific sitemap segment
// ...implementation
}
```
#### JSON-LD Structured Data
Missing structured data for improved search results appearance. For icon pages, implement ImageObject schema:
```typescript
// In [icon]/page.tsx component
import { JsonLd } from 'next-seo';
// Within component return statement
return (
<>
<JsonLd
type="ImageObject"
data={{
'@context': 'https://schema.org',
'@type': 'ImageObject',
name: `${formattedIconName} Icon`,
description: `Dashboard icon for ${formattedIconName}`,
contentUrl: `${BASE_URL}/png/${icon}.png`,
license: 'https://creativecommons.org/licenses/by-sa/4.0/',
acquireLicensePage: `${WEB_URL}/icons/${icon}`,
creditText: `Dashboard Icons`,
creator: {
'@type': 'Person',
name: authorData.name || authorData.login
}
}}
/>
<IconDetails icon={icon} iconData={originalIconData} authorData={authorData} />
</>
)
```
For the homepage, implement Organization schema:
```typescript
// In layout.tsx or page.tsx
import { JsonLd } from 'next-seo';
// Within component return statement
<JsonLd
type="Organization"
data={{
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Dashboard Icons',
url: WEB_URL,
logo: `${WEB_URL}/logo.png`,
description: 'Collection of free icons for self-hosted dashboards and services',
sameAs: [
REPO_PATH,
// Social media links if available
]
}}
/>
```
## Performance Optimization
### Core Web Vitals
Performance is a crucial SEO factor. Current implementation has:
- [x] Image optimization through next/image (reduces LCP)
- [x] Font optimization with the Inter variable font
- [ ] Proper lazy loading of below-the-fold content
- [ ] Optimized Cumulative Layout Shift (CLS)
- [ ] Interaction to Next Paint (INP) optimization
### Detailed Recommendations
#### 1. Image Optimization
- **Priority attribute**: Add priority attribute to critical above-the-fold images:
```tsx
<Image
src="/hero-image.jpg"
alt="Dashboard Icons"
width={1200}
height={630}
priority
/>
```
- **Size optimization**: Ensure images use appropriate sizes for their display contexts:
```tsx
<Image
src={`${BASE_URL}/png/${icon}.png`}
alt={`${formattedIconName} icon`}
width={64}
height={64}
sizes="(max-width: 640px) 32px, (max-width: 1024px) 48px, 64px"
/>
```
#### 2. JavaScript Optimization
- **Use dynamic imports**: Implement dynamic imports for non-critical components:
```tsx
import dynamic from 'next/dynamic'
const IconGrid = dynamic(() => import('@/components/IconGrid'), {
loading: () => <p>Loading icons...</p>,
})
```
- **Component-level code splitting**: Break large components into smaller, more manageable pieces
#### 3. Core Web Vitals Focus
- **LCP Optimization**:
- Preload critical resources
- Optimize server response time
- Prioritize above-the-fold content rendering
- **CLS Minimization**:
- Reserve space for dynamic content
- Define explicit width/height for images and embeds
- Avoid inserting content above existing content
- **INP Improvement**:
- Optimize event handlers
- Use debouncing for input-related events
- Avoid long-running JavaScript tasks
## Content and User Experience
- [x] Clean, semantic HTML structure
- [x] Clear content hierarchy with proper heading tags
- [ ] Comprehensive alt text for all images
- [x] Mobile-friendly responsive design
- [ ] Breadcrumb navigation for improved user experience and SEO
- [ ] Related icons section for internal linking and improved user engagement
### Recommended Content Improvements
#### Breadcrumb Navigation
Implement structured breadcrumb navigation with Schema.org markup:
```tsx
// components/Breadcrumbs.tsx
import Link from 'next/link'
import { JsonLd } from 'next-seo'
interface BreadcrumbItem {
name: string
url: string
}
export function Breadcrumbs({ items }: { items: BreadcrumbItem[] }) {
return (
<>
<JsonLd
type="BreadcrumbList"
data={{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
}}
/>
<nav aria-label="Breadcrumb" className="breadcrumbs">
<ol>
{items.map((item, index) => (
<li key={item.url}>
{index < items.length - 1 ? (
<Link href={item.url}>{item.name}</Link>
) : (
<span aria-current="page">{item.name}</span>
)}
</li>
))}
</ol>
</nav>
</>
)
}
```
#### Related Icons Section
Add a related icons section to improve internal linking and user engagement:
```tsx
// components/RelatedIcons.tsx
import Link from 'next/link'
import Image from 'next/image'
import { BASE_URL } from '@/constants'
export function RelatedIcons({
currentIcon,
similarIcons
}: {
currentIcon: string,
similarIcons: string[]
}) {
return (
<section aria-labelledby="related-icons-heading">
<h2 id="related-icons-heading">Related Icons</h2>
<div className="icon-grid">
{similarIcons.map(icon => (
<Link
key={icon}
href={`/icons/${icon}`}
className="icon-card"
>
<Image
src={`${BASE_URL}/png/${icon}.png`}
alt={`${icon.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')} icon`}
width={48}
height={48}
/>
<span>{icon.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')}</span>
</Link>
))}
</div>
</section>
)
}
```
## Mobile Optimization
- [x] Responsive design with fluid layouts
- [x] Appropriate viewport configuration:
```html
<meta name="viewport" content="width=device-width, initial-scale=1, minimumScale=1, maximumScale=5, userScalable=true, themeColor=#ffffff, viewportFit=cover" />
```
- [ ] Touch-friendly navigation and interface elements (minimum 44x44px tap targets)
- [ ] Mobile page speed optimization (reduced JavaScript, optimized images)
### Mobile-Specific Recommendations
1. **Implement mobile-specific image handling**:
```tsx
<Image
src={`${BASE_URL}/png/${icon}.png`}
alt={`${formattedIconName} icon`}
width={64}
height={64}
sizes="(max-width: 480px) 32px, 64px"
quality={90}
/>
```
2. **Enhanced touch targets for mobile**:
```css
@media (max-width: 768px) {
.nav-link, .button, .interactive-element {
min-height: 44px;
min-width: 44px;
padding: 12px;
}
}
```
3. **Simplified navigation for mobile**:
Implement a hamburger menu or collapsible navigation for mobile devices
## Advanced Next.js 15.3 SEO Features
Next.js 15.3 offers enhanced SEO features that should be implemented:
### Dynamic OG Images
Implement dynamic Open Graph images using the ImageResponse API:
```typescript
// app/icons/[icon]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getAllIcons } from '@/lib/api'
import { BASE_URL } from '@/constants'
export const runtime = 'edge'
export const alt = 'Dashboard Icon Preview'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function OgImage({ params }: { params: { icon: string } }) {
const { icon } = params
const iconsData = await getAllIcons()
const iconData = iconsData[icon]
if (!iconData) {
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#f8fafc',
color: '#334155',
fontFamily: 'sans-serif',
}}
>
<h1 style={{ fontSize: 64 }}>Icon Not Found</h1>
</div>
)
)
}
const formattedIconName = icon
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#f8fafc',
color: '#334155',
fontFamily: 'sans-serif',
padding: 40,
}}
>
<img
src={`${BASE_URL}/png/${icon}.png`}
width={200}
height={200}
alt={`${formattedIconName} icon`}
style={{ marginBottom: 40 }}
/>
<h1 style={{ fontSize: 64, marginBottom: 20, textAlign: 'center' }}>
{formattedIconName} Icon
</h1>
<p style={{ fontSize: 32, textAlign: 'center' }}>
Free download in SVG, PNG, and WEBP formats
</p>
</div>
)
)
}
```
### Next.js Route Segments for SEO
Utilize route segment config options to optimize SEO aspects:
```typescript
// app/icons/[icon]/page.tsx
export const dynamic = 'force-static' // Ensure static generation even with dynamic data fetching
export const revalidate = 3600 // Revalidate content every hour
export const fetchCache = 'force-cache' // Enforce caching of fetched data
export const generateStaticParams = async () => {
// Generate static paths for all icons
const iconsData = await getAllIcons()
return Object.keys(iconsData).map((icon) => ({ icon }))
}
```
### Advanced Caching Strategies
Implement advanced caching with revalidation tags for dynamic content:
```typescript
// lib/api.ts
import { cache, revalidateTag } from 'next/cache'
// Cache API calls using tags
export const getTotalIcons = cache(
async () => {
const response = await fetch(METADATA_URL, {
next: { tags: ['icons-metadata'] },
})
const data = await response.json()
return { totalIcons: Object.keys(data).length }
}
)
// Function to trigger revalidation when new icons are added
export async function revalidateIconsCache() {
revalidateTag('icons-metadata')
}
```
## Recommendations
### Immediate (High Impact/Low Effort)
1. **Create robots.txt**
- Implement a file-based robots.txt using Next.js 15.3 API
- Include sitemap reference
```typescript
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
},
sitemap: 'https://dashboardicons.com/sitemap.xml',
}
}
```
2. **Generate XML Sitemap**
- Create a dynamic sitemap.xml file using Next.js 15.3 file-based API
- Include changefreq and priority attributes
- Implement sitemap index for large icon collections
```typescript
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllIcons } from '@/lib/api'
import { WEB_URL } from '@/constants'
export default async function sitemap(): MetadataRoute.Sitemap {
const iconsData = await getAllIcons()
// Implementation as shown in Technical SEO section
}
```
3. **Add Structured Data**
- Implement JSON-LD for icon pages (ImageObject schema)
- Add WebSite schema to the homepage
- Include BreadcrumbList schema for navigation
```typescript
// app/layout.tsx
import { JsonLd } from 'next-seo'
// In component return
<JsonLd
type="WebSite"
data={{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Dashboard Icons',
url: WEB_URL,
description: getDescription(totalIcons),
potentialAction: {
'@type': 'SearchAction',
target: `${WEB_URL}/icons?q={search_term_string}`,
'query-input': 'required name=search_term_string'
}
}}
/>
```
4. **Enhance Internal Linking**
- Implement breadcrumb navigation
- Add "related icons" or "similar icons" sections
- Create more internal links between icon categories or tags
### Medium-term Improvements
1. **Performance Optimization**
- Implement priority attribute for critical images
- Optimize component-level code splitting
- Refine Largest Contentful Paint (LCP) elements
2. **Enhanced Metadata**
- Implement dynamic OG images with the ImageResponse API
- Add more specific structured data for each icon category
- Implement comprehensive hreflang tags if multilingual support is added
3. **Content Enhancement**
- Add more descriptive text for each icon
- Include usage examples and contexts
- Improve alt text for all images with detailed descriptions
### Long-term Strategy
1. **Advanced Metrics Tracking**
- Implement Real User Monitoring (RUM)
- Set up Core Web Vitals tracking in the field
- Establish regular SEO audit cycles
2. **Enhanced User Experience**
- Implement advanced search functionality with filtering options
- Add user collections/favorites feature
- Develop a comprehensive filtering system by icon type, style, color, etc.
3. **Content Expansion**
- Add tutorials on how to use the icons
- Create themed icon collections
- Implement a blog for icon design tips and updates
## Conclusion
### Overall SEO Health Assessment
The Dashboard Icons website currently implements many SEO best practices through Next.js 15.3's App Router features. The project demonstrates strong implementation of:
- Metadata configuration with the built-in Metadata API
- Dynamic generation of page-specific metadata
- Open Graph and Twitter Card integration
- Server-side rendering and static generation
- Proper canonical URL management
- Clean, semantic HTML structure
- Responsive design for mobile devices
However, several critical components are missing that would significantly improve search engine visibility:
1. **Missing Technical Components**:
- No robots.txt file
- No XML sitemap
- No structured data (JSON-LD)
- Limited use of Next.js 15.3's advanced features
2. **Performance Optimization Gaps**:
- Missing priority attributes on critical images
- Limited implementation of advanced caching strategies
- Potential Core Web Vitals optimizations
3. **Enhanced User Experience Opportunities**:
- No breadcrumb navigation
- Limited internal linking between related icons
- Missing advanced search and filtering capabilities
### SEO Implementation Score
| Category | Score | Notes |
|----------|-------|-------|
| Metadata Implementation | 8/10 | Strong implementation, missing structured data |
| Technical SEO | 6/10 | Missing robots.txt and sitemap |
| Performance | 7/10 | Good image optimization, room for improvement |
| Content Structure | 7/10 | Semantic HTML present, needs better internal linking |
| Mobile Optimization | 8/10 | Responsive design, opportunity for touch optimizations |
| Next.js 15.3 Features | 5/10 | Not utilizing latest features like dynamic OG images |
| Overall | 6.8/10 | Good foundation, specific improvements needed |
### Priority Action Items
1. **Immediate (High Impact/Low Effort)**:
- Create robots.txt file using file-based API
- Generate XML sitemap with Next.js 15.3 API
- Add JSON-LD structured data to all page types
2. **Short-term (Medium Impact)**:
- Optimize Core Web Vitals (LCP, CLS, INP)
- Add priority attribute to above-the-fold images
- Implement breadcrumb navigation with schema
3. **Long-term (Strategic)**:
- Implement dynamic OG images with ImageResponse API
- Add more descriptive content for each icon
- Develop a comprehensive internal linking strategy
- Consider content expansion with tutorials and icon usage guides
By implementing these SEO improvements, Dashboard Icons will significantly enhance its search engine visibility, user experience, and overall organic traffic growth potential. The existing implementation provides a solid foundation, and these targeted enhancements will help maximize the site's search performance in an increasingly competitive landscape.
## References
1. [Next.js 15.3 Metadata API Documentation](https://nextjs.org/docs/app/building-your-application/optimizing/metadata)
2. [Google's SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide)
3. [Next.js File-Based Metadata](https://nextjs.org/docs/app/api-reference/file-conventions/metadata)
4. [Core Web Vitals - Google Web Dev](https://web.dev/articles/vitals)
5. [Schema.org Documentation](https://schema.org/docs/documents.html)
6. [Next.js Image Component Documentation](https://nextjs.org/docs/app/api-reference/components/image)
7. [Next.js ImageResponse API](https://nextjs.org/docs/app/api-reference/functions/image-response)
8. [Google Search Central Documentation](https://developers.google.com/search)
9. [Next.js 15.3 App Router SEO Checklist](https://dev.to/simplr_sh/nextjs-15-app-router-seo-comprehensive-checklist-3d3f)
10. [Mobile Optimization - Google Search Central](https://developers.google.com/search/mobile-sites)
11. [Next.js 15.3 Performance Optimization](https://nextjs.org/docs/app/building-your-application/optimizing)

0
web/backend/.gitkeep Normal file
View File

BIN
web/backend/pocketbase Executable file

Binary file not shown.

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
@@ -7,27 +7,36 @@
},
"files": {
"ignoreUnknown": false,
"ignore": ["src/components/ui/**"]
"includes": ["src/**", "!src/components/ui"]
},
"formatter": {
"lineWidth": 140,
"enabled": true,
"indentStyle": "tab"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"useUniqueElementIds": "off"
},
"suspicious": {
"noExplicitAny": "off",
"noUnknownAtRules": "off",
"noArrayIndexKey": "off"
}
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"javascript": {
"formatter": {
"lineWidth": 140,
"semicolons": "asNeeded",
"quoteStyle": "double"
}

View File

@@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"registries": {
"@magicui": "https://magicui.design/r/{name}.json"
}
}

View File

@@ -4,7 +4,6 @@ const nextConfig: NextConfig = {
images: {
unoptimized: true,
},
output: "export",
}
};
export default nextConfig

View File

@@ -3,12 +3,17 @@
"version": "0.2.0",
"private": false,
"scripts": {
"dev": "next dev --turbopack",
"dev": "turbo run dev:backend dev:web --ui tui",
"dev:backend": "cd backend && ./pocketbase serve",
"dev:web": "next dev --turbopack",
"build": "next build",
"start": "pnpx serve@latest out",
"start": "next start",
"format": "biome check --write",
"lint": "biome lint --write",
"ci": "biome check --write"
"ci": "biome check --write",
"backend:start": "cd backend && ./pocketbase serve",
"backend:download": "cd backend && curl -L -o pocketbase.zip https://github.com/pocketbase/pocketbase/releases/download/v0.30.0/pocketbase_0.30.0_darwin_arm64.zip && unzip pocketbase.zip && rm pocketbase.zip && rm CHANGELOG.md && rm LICENSE.md",
"seed": "bun run seed-db.ts"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
@@ -38,24 +43,33 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@tanstack/react-form": "^1.11.0",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.18",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12",
"input-otp": "^1.4.2",
"lucide-react": "^0.543.0",
"lucide-react": "^0.544.0",
"motion": "^12.23.12",
"next": "15.5.2",
"next": "15.5.4",
"next-themes": "^0.4.6",
"posthog-js": "^1.262.0",
"posthog-node": "^5.8.2",
"pocketbase": "^0.26.2",
"posthog-js": "^1.268.7",
"posthog-node": "^5.9.1",
"radix-ui": "^1.4.3",
"react": "^19.1.1",
"react-day-picker": "8.10.1",
"react-dom": "^19.1.1",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.62.0",
"react-resizable-panels": "^2.1.9",
"recharts": "^2.15.4",
@@ -67,7 +81,7 @@
"zod": "^4.1.5"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@biomejs/biome": "2.2.4",
"@tailwindcss/postcss": "^4.1.13",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^22.18.1",
@@ -77,10 +91,11 @@
"typescript": "^5.9.2",
"wrangler": "^4.35.0"
},
"packageManager": "pnpm@10.15.1",
"packageManager": "pnpm@10.17.1",
"pnpm": {
"onlyBuiltDependencies": [
"@biomejs/biome",
"@tailwindcss/oxide",
"core-js",
"esbuild",
"sharp",

620
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

216
web/seed-db.ts Normal file
View File

@@ -0,0 +1,216 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { pb, type User } from './src/lib/pb.js'
// Constants (matching src/constants.ts)
const BASE_URL = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons"
interface IconMetadata {
base: string
aliases: string[]
categories: string[]
update: {
timestamp: string
author: {
id: number
login?: string
name?: string
}
}
}
interface MetadataFile {
[key: string]: IconMetadata
}
const STATUSES = ['pending', 'approved', 'rejected', 'added_to_collection'] as const
async function getOrCreateUser(email: string, username: string, password: string = 'password123'): Promise<User> {
try {
// Try to authenticate first (if user exists, this will work)
try {
await pb.collection('users').authWithPassword(email, password, {
requestKey: null
})
const user = pb.authStore.record as unknown as User
console.log(`✓ Authenticated existing user: ${email}`)
return user
} catch (authError) {
// User doesn't exist or wrong password, try to create
console.log(` User ${email} not found, creating...`)
}
// Create new user if doesn't exist
const user = await pb.collection('users').create<User>({
email,
username,
password,
passwordConfirm: password,
emailVisibility: true
}, {
requestKey: null
})
console.log(`✓ Created new user: ${email}`)
// Authenticate with the newly created user
await pb.collection('users').authWithPassword(email, password, {
requestKey: null
})
return user
} catch (error: any) {
console.error(`✗ Error with user ${email}:`, error?.message || error)
throw error
}
}
async function downloadImage(iconName: string, format: 'svg' | 'png' | 'webp'): Promise<File> {
const url = `${BASE_URL}/${format}/${iconName}.${format}`
console.log(` Downloading: ${url}`)
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to download ${url}: ${response.statusText}`)
}
// Get the blob from the response
const blob = await response.blob()
// Create a File instance from the blob (like in PocketBase docs)
const file = new File([blob], `${iconName}.${format}`, {
type: blob.type || `image/${format === 'svg' ? 'svg+xml' : format}`
})
return file
}
async function createFakeSubmission(
iconName: string,
iconData: IconMetadata,
user: User,
approvedById?: string
) {
try {
console.log(`\n📝 Creating submission for: ${iconName} (as ${user.email})`)
// Authenticate as the user who will create the submission
console.log(` 🔐 Authenticating as ${user.email}...`)
await pb.collection('users').authWithPassword(user.email, 'password123', {
requestKey: null
})
// Download the image based on the base format (returns File instance)
const format = iconData.base as 'svg' | 'png' | 'webp'
const file = await downloadImage(iconName, format)
// Randomly select a status
const status = STATUSES[Math.floor(Math.random() * STATUSES.length)]
// Prepare submission data (like in PocketBase docs)
const submissionData: Record<string, any> = {
name: iconName,
assets: [file], // files must be Blob or File instances
created_by: user.id,
status: status,
extras: {
aliases: iconData.aliases || [],
categories: iconData.categories || [],
base: iconData.base
}
}
// Only add approved_by if status is approved or added_to_collection
if ((status === 'approved' || status === 'added_to_collection') && approvedById) {
submissionData.approved_by = approvedById
}
const submission = await pb.collection('submissions').create(submissionData, {
requestKey: null // Disable auto-cancellation
})
console.log(`✓ Created submission: ${iconName} (${status})`)
return submission
} catch (error: any) {
console.error(`✗ Failed to create submission for ${iconName}:`, error?.message || error)
if (error?.response) {
console.error(' Response:', JSON.stringify(error.response, null, 2))
}
if (error?.data) {
console.error(' Data:', JSON.stringify(error.data, null, 2))
}
throw error
}
}
async function main() {
console.log('🚀 Starting fake submissions generator\n')
// Read metadata.json
const metadataPath = join(process.cwd(), '..', 'metadata.json')
console.log(`📖 Reading metadata from: ${metadataPath}`)
const metadataContent = await readFile(metadataPath, 'utf-8')
const metadata: MetadataFile = JSON.parse(metadataContent)
const iconNames = Object.keys(metadata)
console.log(`✓ Found ${iconNames.length} icons in metadata\n`)
// Create or get users sequentially to avoid conflicts
console.log('👥 Setting up users...')
const user1 = await getOrCreateUser('user1@example.com', 'user1')
const user2 = await getOrCreateUser('user2@example.com', 'user2')
const user3 = await getOrCreateUser('user3@example.com', 'user3')
const adminUser = await getOrCreateUser('admin@example.com', 'admin')
const users = [user1, user2, user3, adminUser]
// Select random number of icons to create submissions for
const numberOfSubmissions = parseInt(process.argv[2]) || 5
console.log(`\n🎲 Creating ${numberOfSubmissions} random submissions...\n`)
const selectedIndices = new Set<number>()
while (selectedIndices.size < numberOfSubmissions) {
selectedIndices.add(Math.floor(Math.random() * iconNames.length))
}
const submissions = []
for (const index of selectedIndices) {
const iconName = iconNames[index]
const iconData = metadata[iconName]
// Randomly select a user
const randomUser = users[Math.floor(Math.random() * users.length)]
try {
const submission = await createFakeSubmission(
iconName,
iconData,
randomUser, // Pass full user object
adminUser.id
)
submissions.push(submission)
} catch (error: any) {
console.error(`✗ Skipping ${iconName} due to error:`, error?.message || error)
if (error?.data) {
console.error(' Error details:', JSON.stringify(error.data, null, 2))
}
}
}
console.log(`\n✨ Successfully created ${submissions.length} submissions!`)
console.log('\n📊 Summary:')
console.log(` - Pending: ${submissions.filter(s => s.status === 'pending').length}`)
console.log(` - Approved: ${submissions.filter(s => s.status === 'approved').length}`)
console.log(` - Rejected: ${submissions.filter(s => s.status === 'rejected').length}`)
console.log(` - Added to Collection: ${submissions.filter(s => s.status === 'added_to_collection').length}`)
// Clear auth store
pb.authStore.clear()
}
main().catch((error) => {
console.error('\n❌ Fatal error:', error)
process.exit(1)
})

View File

@@ -0,0 +1,36 @@
"use server"
import { revalidateCommunityPage, revalidateSubmissions } from "@/lib/revalidate"
/**
* Server actions for submission management
* These can be called from client components to trigger cache revalidation
*/
/**
* Revalidate the community page after a submission status change
* Call this after approving, rejecting, or adding a submission to the collection
*/
export async function revalidateCommunitySubmissions() {
try {
await revalidateCommunityPage()
return { success: true }
} catch (error) {
console.error("Error revalidating community page:", error)
return { success: false, error: "Failed to revalidate" }
}
}
/**
* Revalidate all submission-related pages
* Use this for actions that affect both the dashboard and community pages
*/
export async function revalidateAllSubmissions() {
try {
await revalidateSubmissions()
return { success: true }
} catch (error) {
console.error("Error revalidating submissions:", error)
return { success: false, error: "Failed to revalidate" }
}
}

View File

@@ -0,0 +1,23 @@
import type React from "react"
import { cn } from "@/lib/utils"
interface BackgroundWrapperProps {
children: React.ReactNode
}
export default function BackgroundWrapper({ children }: BackgroundWrapperProps) {
return (
<div className="relative min-h-screen w-full">
<div
className={cn(
"absolute inset-0",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-background" />
<div className="z-20 relative">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import type { Metadata } from "next"
import { Suspense } from "react"
import { CommunityIconSearch } from "@/components/community-icon-search"
import { BASE_URL } from "@/constants"
import { getCommunitySubmissions } from "@/lib/community"
export async function generateMetadata(): Promise<Metadata> {
const icons = await getCommunitySubmissions()
const totalIcons = icons.length
return {
title: "Browse Community Icons | Dashboard Icons",
description: `Search and browse through ${totalIcons} community-submitted icons awaiting review and addition to the Dashboard Icons collection.`,
keywords: [
"community icons",
"browse community icons",
"icon submissions",
"community contributions",
"pending icons",
"approved icons",
"dashboard icons community",
"user submitted icons",
],
openGraph: {
title: "Browse Community Icons | Dashboard Icons",
description: `Search and browse through ${totalIcons} community-submitted icons awaiting review and addition to the Dashboard Icons collection.`,
type: "website",
url: `${BASE_URL}/community`,
},
twitter: {
card: "summary_large_image",
title: "Browse Community Icons | Dashboard Icons",
description: `Search and browse through ${totalIcons} community-submitted icons awaiting review and addition to the Dashboard Icons collection.`,
},
alternates: {
canonical: `${BASE_URL}/community`,
},
}
}
export const revalidate = 600
export default async function CommunityPage() {
const icons = await getCommunitySubmissions()
return (
<div className="isolate overflow-hidden p-2 mx-auto max-w-7xl">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold">Browse community icons</h1>
<p className="text-muted-foreground mb-1">Search through our collection of {icons.length} community-submitted icons.</p>
</div>
</div>
<Suspense fallback={<div className="text-muted-foreground">Loading...</div>}>
<CommunityIconSearch icons={icons as any} />
</Suspense>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import type React from "react"
import { cn } from "@/lib/utils"
interface BackgroundWrapperProps {
children: React.ReactNode
}
export default function BackgroundWrapper({ children }: BackgroundWrapperProps) {
return (
<div className="relative min-h-screen w-full">
<div
className={cn(
"absolute inset-0",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-background" />
<div className="z-20 relative">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,130 @@
"use client"
import { AlertCircle, RefreshCw } from "lucide-react"
import { SubmissionsDataTable } from "@/components/submissions-data-table"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { useApproveSubmission, useAuth, useRejectSubmission, useSubmissions } from "@/hooks/use-submissions"
export default function DashboardPage() {
// Fetch auth status
const { data: auth, isLoading: authLoading } = useAuth()
// Fetch submissions
const { data: submissions = [], isLoading: submissionsLoading, error: submissionsError, refetch } = useSubmissions()
// Mutations
const approveMutation = useApproveSubmission()
const rejectMutation = useRejectSubmission()
const isLoading = authLoading || submissionsLoading
const isAuthenticated = auth?.isAuthenticated ?? false
const isAdmin = auth?.isAdmin ?? false
const currentUserId = auth?.userId ?? ""
const handleApprove = (submissionId: string) => {
approveMutation.mutate(submissionId)
}
const handleReject = (submissionId: string) => {
rejectMutation.mutate(submissionId)
}
// Not authenticated
if (!authLoading && !isAuthenticated) {
return (
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>Access Denied</CardTitle>
<CardDescription>You need to be logged in to access the dashboard.</CardDescription>
</CardHeader>
</Card>
</main>
)
}
// Loading state
if (isLoading) {
return (
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
</CardContent>
</Card>
</main>
)
}
// Error state
if (submissionsError) {
return (
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>Submissions Dashboard</CardTitle>
<CardDescription>
{isAdmin ? "Review and manage all icon submissions." : "View your icon submissions and track their status."}
</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error loading submissions</AlertTitle>
<AlertDescription>
Failed to load submissions. Please try again.
<Button variant="outline" size="sm" className="ml-4" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
</CardContent>
</Card>
</main>
)
}
// Success state
return (
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<Card className="bg-background/50 border-none shadow-lg">
<CardHeader>
<CardTitle>Submissions Dashboard</CardTitle>
<CardDescription>
{isAdmin
? "Review and manage all icon submissions. Click on a row to see details."
: "View your icon submissions and track their status."}
</CardDescription>
</CardHeader>
<CardContent>
<SubmissionsDataTable
data={submissions}
isAdmin={isAdmin}
currentUserId={currentUserId}
onApprove={handleApprove}
onReject={handleReject}
isApproving={approveMutation.isPending}
isRejecting={rejectMutation.isPending}
/>
</CardContent>
</Card>
</main>
)
}

View File

@@ -1,18 +1,11 @@
"use client"
import { Button } from "@/components/ui/button"
import { AlertTriangle, ArrowLeft, RefreshCcw } from "lucide-react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
import { Button } from "@/components/ui/button"
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
const router = useRouter()
useEffect(() => {

View File

@@ -42,6 +42,10 @@
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
/* --font-sans: Open Sans, sans-serif;
--font-mono: Menlo, monospace;
--font-serif: Georgia, serif; */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
@@ -118,112 +122,169 @@
transform: rotate(-5deg) scale(0.9);
}
}
--radius: 0.3rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color)
}
:root {
--radius: 0.4rem;
--radius: 0.3rem;
--background: oklch(0.99 0 0);
--foreground: oklch(0.32 0 0);
--card: oklch(1.0 0 0);
--card-foreground: oklch(0.32 0 0);
--popover: oklch(1.0 0 0);
--popover-foreground: oklch(0.32 0 0);
--primary: oklch(0.67 0.2 23.8);
--primary-foreground: oklch(1.0 0 0);
--secondary: oklch(0.97 0.0 264.54);
--secondary-foreground: oklch(0.45 0.03 256.8);
--muted: oklch(0.98 0.0 247.84);
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1884 0.0128 248.5103);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1884 0.0128 248.5103);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1884 0.0128 248.5103);
--primary: oklch(0.6723 0.1606 244.9955);
--primary-foreground: oklch(1.0000 0 0);
--muted: oklch(0.98 0 247.84);
--muted-foreground: oklch(0.55 0.02 264.36);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.64 0.21 25.33);
--destructive-foreground: oklch(1.0 0 0);
--border: oklch(0.9 0.01 247.88);
--accent: oklch(0.9392 0.0166 250.8453);
--accent-foreground: oklch(0.6723 0.1606 244.9955);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.9317 0.0118 231.6594);
--input: oklch(0.92 0.004 286.32);
--input: oklch(0.9809 0.0025 228.7836);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.637 0.237 25.331);
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.637 0.237 25.331);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.9784 0.0011 197.1387);
--sidebar-foreground: oklch(0.1884 0.0128 248.5103);
--sidebar-primary: oklch(0.6723 0.1606 244.9955);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9392 0.0166 250.8453);
--sidebar-accent-foreground: oklch(0.6723 0.1606 244.9955);
--sidebar-border: oklch(0.9271 0.0101 238.5177);
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 2px 4px -1px
hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 4px 6px -1px
hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 8px 10px -1px
hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.25);
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--magic-gradient-color: oklch(0.67 0.2 23.8 / 15%);
--magic-gradient-color: oklch(0.6723 0.1606 244.9955 / 15%);
--ring: oklch(0.6818 0.1584 243.3540);
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--shadow-color: rgba(29,161,242,0.15);
--shadow-opacity: 0;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 2px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--tracking-normal: 0em;
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.92 0 0);
--card: oklch(0.31 0.03 268.64);
--card-foreground: oklch(0.92 0 0);
--popover: oklch(0.29 0.02 268.4);
--popover-foreground: oklch(0.92 0 0);
--primary: oklch(0.67 0.2 23.8);
--primary-foreground: oklch(1.0 0 0);
--secondary: oklch(0.31 0.03 266.71);
--secondary-foreground: oklch(0.92 0 0);
--background: oklch(0 0 0);
--foreground: oklch(0.9328 0.0025 228.7857);
--card: oklch(0.2097 0.0080 274.5332);
--card-foreground: oklch(0.8853 0 0);
--popover: oklch(0 0 0);
--popover-foreground: oklch(0.9328 0.0025 228.7857);
--primary: oklch(0.6700 0.2000 23.8000);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9622 0.0035 219.5331);
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
--muted: oklch(0.31 0.03 266.71);
--muted-foreground: oklch(0.78 0 0);
--accent: oklch(0.34 0.06 267.59);
--accent-foreground: oklch(0.88 0.06 254.13);
--destructive: oklch(0.64 0.21 25.33);
--destructive-foreground: oklch(1.0 0 0);
--accent: oklch(0.1928 0.0331 242.5459);
--accent-foreground: oklch(0.6448 0.2290 20.4673);
--destructive: oklch(0.6188 0.2376 25.7658);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.38 0.03 269.73);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.637 0.237 25.331);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.637 0.237 25.331);
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.637 0.237 25.331);
--input: oklch(0.3020 0.0288 244.8244);
--ring: oklch(0.6700 0.2000 23.8000);
--chart-1: oklch(0.6723 0.1606 244.9955);
--chart-2: oklch(0.6907 0.1554 160.3454);
--chart-3: oklch(0.8214 0.1600 82.5337);
--chart-4: oklch(0.7064 0.1822 151.7125);
--chart-5: oklch(0.5919 0.2186 10.5826);
--sidebar: oklch(0.2097 0.0080 274.5332);
--sidebar-foreground: oklch(0.8853 0 0);
--sidebar-primary: oklch(0.6700 0.2000 23.8000);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
--sidebar-accent-foreground: oklch(0.5869 0.2251 31.5657);
--sidebar-border: oklch(0.3795 0.0220 240.5943);
--sidebar-ring: oklch(0.4952 0.1902 31.5028);
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
hsl(0 0% 0% / 0.1);
--shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 2px 4px -1px
hsl(0 0% 0% / 0.1);
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 4px 6px -1px
hsl(0 0% 0% / 0.1);
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 8px 10px -1px
hsl(0 0% 0% / 0.1);
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.25);
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
--magic-gradient-color: oklch(0.67 0.2 23.8 / 15%);
--radius: 0.3rem;
--shadow-color: rgba(29,161,242,0.25);
--shadow-opacity: 0;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 2px;
--magic-gradient-color: oklch(0.27 0 0);
}
@layer base {
@@ -232,6 +293,7 @@
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
}
@@ -252,3 +314,31 @@
@apply backdrop-blur-sm;
}
}
/* Enhanced Input Styling */
@layer components {
input[type="text"],
input[type="email"],
input[type="password"],
input[type="search"] {
@apply bg-background text-foreground;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="search"]:focus {
@apply ring-2 ring-ring ring-offset-2 ring-offset-background;
}
/* Error state for inputs */
input[aria-invalid="true"],
input.error {
@apply border-destructive bg-destructive/5 text-foreground placeholder:text-muted-foreground;
}
input[aria-invalid="true"]:focus,
input.error:focus {
@apply ring-2 ring-destructive ring-offset-2 ring-offset-background;
}
}

View File

@@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises"
import { join } from "node:path"
import { getAllIcons } from "@/lib/api"
import { ImageResponse } from "next/og"
import { getAllIcons } from "@/lib/api"
export const dynamic = "force-static"
@@ -42,7 +42,7 @@ export default async function Image({ params }: { params: { icon: string } }) {
const iconPath = join(process.cwd(), `../png/${icon}.png`)
console.log(`Generating opengraph image for ${icon} (${index + 1} / ${totalIcons}) from path ${iconPath}`)
iconData = await readFile(iconPath)
} catch (error) {
} catch (_error) {
console.error(`Icon ${icon} was not found locally`)
}

View File

@@ -1,8 +1,8 @@
import type { Metadata, ResolvingMetadata } from "next"
import { notFound } from "next/navigation"
import { IconDetails } from "@/components/icon-details"
import { BASE_URL, WEB_URL } from "@/constants"
import { getAllIcons, getAuthorData } from "@/lib/api"
import type { Metadata, ResolvingMetadata } from "next"
import { notFound } from "next/navigation"
export const dynamicParams = false
export async function generateStaticParams() {
@@ -19,7 +19,7 @@ type Props = {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export async function generateMetadata({ params, searchParams }: Props, parent: ResolvingMetadata): Promise<Metadata> {
export async function generateMetadata({ params, searchParams }: Props, _parent: ResolvingMetadata): Promise<Metadata> {
const { icon } = await params
const iconsData = await getAllIcons()
if (!iconsData[icon]) {
@@ -76,13 +76,14 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
type: "website",
url: pageUrl,
siteName: "Dashboard Icons",
images: [{
images: [
{
url: `${BASE_URL}/webp/${icon}.webp`,
width: 512,
height: 512,
alt: `${formattedIconName} icon`,
}]
},
],
},
twitter: {
card: "summary_large_image",

View File

@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils"
import type React from "react"
import { cn } from "@/lib/utils"
interface BackgroundWrapperProps {
children: React.ReactNode

View File

@@ -1,5 +1,5 @@
import { getAllIcons } from "@/lib/api"
import { ImageResponse } from "next/og"
import { getAllIcons } from "@/lib/api"
export const dynamic = "force-static"

View File

@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import { IconSearch } from "@/components/icon-search"
import { BASE_URL } from "@/constants"
import { getIconsArray } from "@/lib/api"
import type { Metadata } from "next"
export async function generateMetadata(): Promise<Metadata> {
const icons = await getIconsArray()

View File

@@ -1,13 +1,14 @@
import { PostHogProvider } from "@/components/PostHogProvider"
import { Footer } from "@/components/footer"
import { HeaderWrapper } from "@/components/header-wrapper"
import { LicenseNotice } from "@/components/license-notice"
import { BASE_URL, WEB_URL, getDescription, websiteTitle } from "@/constants"
import { getTotalIcons } from "@/lib/api"
import type { Metadata, Viewport } from "next"
import { Inter } from "next/font/google"
import { Toaster } from "sonner"
import { Footer } from "@/components/footer"
import { HeaderWrapper } from "@/components/header-wrapper"
import { LicenseNotice } from "@/components/license-notice"
import { PostHogProvider } from "@/components/PostHogProvider"
import { BASE_URL, getDescription, WEB_URL, websiteTitle } from "@/constants"
import { getTotalIcons } from "@/lib/api"
import "./globals.css"
import { Providers } from "@/components/providers"
import { ThemeProvider } from "./theme-provider"
const inter = Inter({
@@ -85,6 +86,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}>
<Providers>
<PostHogProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<HeaderWrapper />
@@ -94,6 +96,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
<LicenseNotice />
</ThemeProvider>
</PostHogProvider>
</Providers>
</body>
</html>
)

View File

@@ -1,13 +1,9 @@
import { AlertTriangle, ArrowLeft } from "lucide-react"
import Link from "next/link"
import { IconSubmissionContent } from "@/components/icon-submission-form"
import { Button } from "@/components/ui/button"
import { AlertTriangle, ArrowLeft, PlusCircle } from "lucide-react"
import Link from "next/link"
export default function NotFound({
error,
}: {
error: Error & { digest?: string }
}) {
export default function NotFound({ error }: { error: Error & { digest?: string } }) {
return (
<div className="py-16 flex items-center justify-center">
<div className="text-center space-y-8 max-w-2xl mx-auto">
@@ -27,17 +23,6 @@ export default function NotFound({
</Link>
</Button>
</div>
<div className="border-t border-border pt-8 mt-8">
<div className="text-center mb-6">
<h2 className="text-xl font-semibold">Missing an icon?</h2>
<p className="text-muted-foreground mt-2">Submit a new icon or suggest improvements to our collection.</p>
</div>
<div className="mt-6">
<IconSubmissionContent />
</div>
</div>
</div>
</div>
)

View File

@@ -1,6 +1,6 @@
import type { MetadataRoute } from "next"
import { BASE_URL, WEB_URL } from "@/constants"
import { getAllIcons } from "@/lib/api"
import type { MetadataRoute } from "next"
export const dynamic = "force-static"

View File

@@ -0,0 +1,23 @@
import type React from "react"
import { cn } from "@/lib/utils"
interface BackgroundWrapperProps {
children: React.ReactNode
}
export default function BackgroundWrapper({ children }: BackgroundWrapperProps) {
return (
<div className="relative min-h-screen w-full">
<div
className={cn(
"absolute inset-0",
"[background-size:40px_40px]",
"[background-image:linear-gradient(to_right,#e4e4e7_1px,transparent_1px),linear-gradient(to_bottom,#e4e4e7_1px,transparent_1px)]",
"dark:[background-image:linear-gradient(to_right,#262626_1px,transparent_1px),linear-gradient(to_bottom,#262626_1px,transparent_1px)]",
)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background [mask-image:radial-gradient(ellipse_at_center,transparent_20%,black)] dark:bg-background" />
<div className="z-20 relative">{children}</div>
</div>
)
}

View File

@@ -0,0 +1,90 @@
"use client"
import { useEffect, useState } from "react"
import { AdvancedIconSubmissionFormTanStack } from "@/components/advanced-icon-submission-form-tanstack"
import { LoginModal } from "@/components/login-modal"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { pb } from "@/lib/pb"
export default function SubmitPage() {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [showLoginModal, setShowLoginModal] = useState(false)
useEffect(() => {
const checkAuth = () => {
setIsAuthenticated(pb.authStore.isValid)
setIsLoading(false)
}
checkAuth()
// Subscribe to auth changes
pb.authStore.onChange(() => {
checkAuth()
})
}, [])
if (isLoading) {
return (
<div className="container mx-auto px-4 py-12">
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
)
}
if (!isAuthenticated) {
return (
<>
<div className="container mx-auto px-4 py-12">
<div className="max-w-2xl mx-auto">
<Card>
<CardHeader className="text-center space-y-4">
<CardTitle className="text-3xl">Submit an Icon</CardTitle>
<CardDescription className="text-base">
Share your icons with the community and help expand our collection
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="bg-muted/50 p-6 rounded-lg space-y-4">
<h3 className="font-semibold text-lg">Before you start</h3>
<ul className="space-y-2 text-sm text-muted-foreground list-disc list-inside">
<li>You need to be logged in to submit icons</li>
<li>Icons should be in SVG, PNG, or WebP format</li>
<li>Maximum file size: 5MB per variant</li>
<li>All submissions are reviewed before being added to the collection</li>
</ul>
</div>
<div className="flex justify-center pt-4">
<Button size="lg" onClick={() => setShowLoginModal(true)}>
Sign In to Submit
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
<LoginModal open={showLoginModal} onOpenChange={setShowLoginModal} />
</>
)
}
return (
<div className="container mx-auto px-4 py-12">
<div className="mb-8 text-center">
<h1 className="text-4xl font-bold mb-2">Submit an Icon</h1>
<p className="text-muted-foreground text-lg">
{isAuthenticated ? "Create a new icon or update an existing one" : "Sign in to submit icons"}
</p>
</div>
<AdvancedIconSubmissionFormTanStack />
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { usePathname, useSearchParams } from "next/navigation"
import posthog from "posthog-js"
import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"
import { Suspense, useEffect } from "react"
import { usePostHogAuth } from "@/hooks/use-posthog-auth"
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
@@ -14,6 +15,7 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://eu.i.posthog.com",
capture_pageview: false, // We capture pageviews manually
capture_pageleave: true, // Enable pageleave capture
person_profiles: 'identified_only',
loaded(posthogInstance) {
// @ts-expect-error
window.posthog = posthogInstance
@@ -23,12 +25,18 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
return (
<PHProvider client={posthog}>
<PostHogAuthHandler />
<SuspendedPostHogPageView />
{children}
</PHProvider>
)
}
function PostHogAuthHandler() {
usePostHogAuth()
return null
}
function PostHogPageView() {
const pathname = usePathname()
const searchParams = useSearchParams()

View File

@@ -0,0 +1,592 @@
"use client"
import { Check, FileImage, FileType, Plus, X } from "lucide-react"
import { useForm } from "@tanstack/react-form"
import { toast } from "sonner"
import { IconNameCombobox } from "@/components/icon-name-combobox"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select"
import { Dropzone, DropzoneContent, DropzoneEmptyState } from "@/components/ui/shadcn-io/dropzone"
import { Textarea } from "@/components/ui/textarea"
import { pb } from "@/lib/pb"
import { useExistingIconNames } from "@/hooks/use-submissions"
import { useState } from "react"
interface VariantConfig {
id: string
label: string
description: string
field: "base" | "dark" | "light" | "wordmark" | "wordmark_dark"
}
const VARIANTS: VariantConfig[] = [
{
id: "base",
label: "Base Icon",
description: "Main icon file (required)",
field: "base",
},
{
id: "dark",
label: "Dark Variant",
description: "Icon optimized for dark backgrounds",
field: "dark",
},
{
id: "light",
label: "Light Variant",
description: "Icon optimized for light backgrounds",
field: "light",
},
{
id: "wordmark",
label: "Wordmark",
description: "Logo with text/wordmark",
field: "wordmark",
},
{
id: "wordmark_dark",
label: "Wordmark Dark",
description: "Wordmark optimized for dark backgrounds",
field: "wordmark_dark",
},
]
// Convert VARIANTS to MultiSelect options
const VARIANT_OPTIONS: MultiSelectOption[] = VARIANTS.map((variant) => ({
label: variant.label,
value: variant.id,
icon: variant.id === "base" ? FileImage : FileType,
disabled: variant.id === "base", // Base is always required
}))
const AVAILABLE_CATEGORIES = [
"automation",
"cloud",
"database",
"development",
"entertainment",
"finance",
"gaming",
"home-automation",
"media",
"monitoring",
"network",
"security",
"social",
"storage",
"tools",
"utility",
"other",
]
interface FormData {
iconName: string
selectedVariants: string[]
files: Record<string, File[]>
filePreviews: Record<string, string>
aliases: string[]
aliasInput: string
categories: string[]
description: string
}
export function AdvancedIconSubmissionFormTanStack() {
const [filePreviews, setFilePreviews] = useState<Record<string, string>>({})
const { data: existingIcons = [] } = useExistingIconNames()
const form = useForm({
defaultValues: {
iconName: "",
selectedVariants: ["base"], // Base is always selected by default
files: {},
filePreviews: {},
aliases: [],
aliasInput: "",
categories: [],
description: "",
} as FormData,
onSubmit: async ({ value }) => {
if (!pb.authStore.isValid) {
toast.error("You must be logged in to submit an icon")
return
}
try {
const assetFiles: File[] = []
// Add base file
if (value.files.base?.[0]) {
assetFiles.push(value.files.base[0])
}
// Build extras object
const extras: any = {
aliases: value.aliases,
categories: value.categories,
base: value.files.base[0]?.name.split(".").pop() || "svg",
}
// Add color variants if present
if (value.files.dark?.[0] || value.files.light?.[0]) {
extras.colors = {}
if (value.files.dark?.[0]) {
extras.colors.dark = value.files.dark[0].name
assetFiles.push(value.files.dark[0])
}
if (value.files.light?.[0]) {
extras.colors.light = value.files.light[0].name
assetFiles.push(value.files.light[0])
}
}
// Add wordmark variants if present
if (value.files.wordmark?.[0] || value.files.wordmark_dark?.[0]) {
extras.wordmark = {}
if (value.files.wordmark?.[0]) {
extras.wordmark.light = value.files.wordmark[0].name
assetFiles.push(value.files.wordmark[0])
}
if (value.files.wordmark_dark?.[0]) {
extras.wordmark.dark = value.files.wordmark_dark[0].name
assetFiles.push(value.files.wordmark_dark[0])
}
}
// Create submission
const submissionData = {
name: value.iconName,
assets: assetFiles,
created_by: pb.authStore.model?.id,
status: "pending",
extras: extras,
}
await pb.collection("submissions").create(submissionData)
toast.success("Icon submitted!", {
description: `Your icon "${value.iconName}" has been submitted for review`,
})
// Reset form
form.reset()
setFilePreviews({})
} catch (error: any) {
console.error("Submission error:", error)
toast.error("Failed to submit icon", {
description: error?.message || "Please try again later",
})
}
},
})
const handleRemoveVariant = (variantId: string) => {
if (variantId !== "base") {
// Remove from selected variants
const currentVariants = form.getFieldValue("selectedVariants")
form.setFieldValue("selectedVariants", currentVariants.filter((v) => v !== variantId))
// Remove files
const currentFiles = form.getFieldValue("files")
const newFiles = { ...currentFiles }
delete newFiles[variantId]
form.setFieldValue("files", newFiles)
// Remove previews
const newPreviews = { ...filePreviews }
delete newPreviews[variantId]
setFilePreviews(newPreviews)
}
}
const handleVariantSelectionChange = (selectedValues: string[]) => {
// Ensure base is always included
const finalValues = selectedValues.includes("base")
? selectedValues
: ["base", ...selectedValues]
return finalValues
}
const handleFileDrop = (variantId: string, droppedFiles: File[]) => {
const currentFiles = form.getFieldValue("files")
form.setFieldValue("files", {
...currentFiles,
[variantId]: droppedFiles,
})
// Generate preview for the first file
if (droppedFiles.length > 0) {
const reader = new FileReader()
reader.onload = (e) => {
if (typeof e.target?.result === 'string') {
setFilePreviews({
...filePreviews,
[variantId]: e.target.result,
})
}
}
reader.readAsDataURL(droppedFiles[0])
}
}
const handleAddAlias = () => {
const aliasInput = form.getFieldValue("aliasInput")
const trimmedAlias = aliasInput.trim()
if (trimmedAlias) {
const currentAliases = form.getFieldValue("aliases")
if (!currentAliases.includes(trimmedAlias)) {
form.setFieldValue("aliases", [...currentAliases, trimmedAlias])
}
form.setFieldValue("aliasInput", "")
}
}
const handleRemoveAlias = (alias: string) => {
const currentAliases = form.getFieldValue("aliases")
form.setFieldValue("aliases", currentAliases.filter((a) => a !== alias))
}
const toggleCategory = (category: string) => {
const currentCategories = form.getFieldValue("categories")
if (currentCategories.includes(category)) {
form.setFieldValue("categories", currentCategories.filter((c) => c !== category))
} else {
form.setFieldValue("categories", [...currentCategories, category])
}
}
return (
<div className="max-w-4xl mx-auto">
<form
onSubmit={(e) => {
e.preventDefault()
e.stopPropagation()
form.handleSubmit()
}}
>
<Card>
<CardHeader>
<CardTitle>Submit an Icon</CardTitle>
<CardDescription>Fill in the details below to submit your icon for review</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Icon Name Section */}
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-1">Icon Identification</h3>
<p className="text-sm text-muted-foreground">Choose a unique identifier for your icon</p>
</div>
<form.Field
name="iconName"
validators={{
onChange: ({ value }) => {
if (!value) return "Icon name is required"
if (!/^[a-z0-9-]+$/.test(value)) {
return "Icon name must contain only lowercase letters, numbers, and hyphens"
}
// Check if icon already exists
const iconExists = existingIcons.some((icon) => icon.value === value)
if (iconExists) {
return "This icon already exists. Icon updates are not yet supported. Please choose a different name."
}
return undefined
},
}}
>
{(field) => (
<div className="space-y-2">
<Label htmlFor="icon-name">Icon Name / ID</Label>
<IconNameCombobox
value={field.state.value}
onValueChange={field.handleChange}
error={field.state.meta.errors.join(", ")}
isInvalid={!field.state.meta.isValid && field.state.meta.isTouched}
/>
<p className="text-sm text-muted-foreground">Use lowercase letters, numbers, and hyphens only</p>
</div>
)}
</form.Field>
</div>
{/* Icon Preview Section */}
{Object.keys(filePreviews).length > 0 && (
<form.Subscribe selector={(state) => ({ iconName: state.values.iconName, categories: state.values.categories })}>
{(state) => (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-1">Icon Preview</h3>
<p className="text-sm text-muted-foreground">How your icon will appear</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{Object.entries(filePreviews).map(([variantId, preview]) => (
<div key={variantId} className="flex flex-col gap-2">
<div className="relative aspect-square rounded-lg border bg-card p-4 flex items-center justify-center">
<img
alt={`${variantId} preview`}
className="max-h-full max-w-full object-contain"
src={preview}
/>
</div>
<div className="text-center">
<p className="text-xs font-mono text-muted-foreground">{state.iconName || "preview"}</p>
<p className="text-xs text-muted-foreground capitalize">{variantId}</p>
</div>
</div>
))}
</div>
</div>
)}
</form.Subscribe>
)}
{/* Icon Variants Section */}
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-1">Icon Variants</h3>
<p className="text-sm text-muted-foreground">Select which variants you want to upload</p>
</div>
<form.Field name="selectedVariants">
{(field) => (
<>
<div className="space-y-3">
<Label>Variant Selection</Label>
<MultiSelect
options={VARIANT_OPTIONS}
defaultValue={field.state.value}
onValueChange={(values) => {
const finalValues = handleVariantSelectionChange(values)
field.handleChange(finalValues)
}}
placeholder="Select icon variants..."
maxCount={5}
searchable={false}
hideSelectAll={true}
resetOnDefaultValueChange={true}
/>
<p className="text-sm text-muted-foreground">
Base icon is required and cannot be removed. Select additional variants as needed.
</p>
</div>
{/* Upload zones for selected variants - using field.state.value for reactivity */}
<div className="grid gap-3 mt-4">
{field.state.value.map((variantId) => {
const variant = VARIANTS.find((v) => v.id === variantId)
if (!variant) return null
const hasFile = form.getFieldValue("files")[variant.id]?.length > 0
const isBase = variant.id === "base"
return (
<Card
key={variantId}
className={`relative transition-all ${
hasFile
? "border-primary bg-primary/5"
: "border-border"
}`}
>
{/* Remove button at top-right corner */}
{!isBase && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveVariant(variant.id)}
className="absolute top-2 right-2 h-6 w-6 rounded-full hover:bg-destructive/10 hover:text-destructive z-10"
aria-label={`Remove ${variant.label}`}
>
<X className="h-4 w-4" />
</Button>
)}
<div className="p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{variant.label}</h4>
{isBase && <Badge variant="secondary" className="text-xs">Required</Badge>}
{hasFile && (
<Badge variant="default" className="text-xs">
<Check className="h-3 w-3 mr-1" />
Uploaded
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">{variant.description}</p>
<Dropzone
accept={{
"image/svg+xml": [".svg"],
"image/png": [".png"],
"image/webp": [".webp"],
}}
maxSize={1024 * 1024 * 5}
maxFiles={1}
onDrop={(droppedFiles) => handleFileDrop(variant.id, droppedFiles)}
onError={(error) => toast.error(error.message)}
src={form.getFieldValue("files")[variant.id]}
>
<DropzoneEmptyState />
<DropzoneContent>
{filePreviews[variant.id] && (
<div className="absolute inset-0 flex items-center justify-center p-2">
<img
alt={`${variant.label} preview`}
className="max-h-full max-w-full object-contain"
src={filePreviews[variant.id]}
/>
</div>
)}
</DropzoneContent>
</Dropzone>
</div>
</div>
</Card>
)
})}
</div>
</>
)}
</form.Field>
</div>
{/* Metadata Section */}
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold mb-1">Icon Metadata</h3>
<p className="text-sm text-muted-foreground">Provide additional information about your icon</p>
</div>
{/* Categories */}
<form.Field name="categories">
{(field) => (
<div className="space-y-3">
<Label>Categories</Label>
<div className="flex flex-wrap gap-2">
{AVAILABLE_CATEGORIES.map((category) => (
<Badge
key={category}
variant={field.state.value.includes(category) ? "default" : "outline"}
className="cursor-pointer hover:bg-primary/80"
onClick={() => toggleCategory(category)}
>
{category.replace(/-/g, " ")}
</Badge>
))}
</div>
<p className="text-sm text-muted-foreground">Select all categories that apply to your icon</p>
{!field.state.meta.isValid && field.state.meta.isTouched && (
<p className="text-sm text-destructive">{field.state.meta.errors.join(", ")}</p>
)}
</div>
)}
</form.Field>
{/* Aliases */}
<div className="space-y-3">
<Label htmlFor="alias-input">Aliases</Label>
<form.Field name="aliasInput">
{(field) => (
<div className="flex gap-2">
<Input
id="alias-input"
placeholder="Add alternative name..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddAlias()
}
}}
/>
<Button type="button" onClick={handleAddAlias}>
<Plus className="h-4 w-4 mr-2" />
Add
</Button>
</div>
)}
</form.Field>
<form.Field name="aliases">
{(field) => (
<>
{field.state.value.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{field.state.value.map((alias) => (
<Badge key={alias} variant="secondary" className="flex items-center gap-1 pl-2 pr-1">
{alias}
<Button
type="button"
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => handleRemoveAlias(alias)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
)}
</>
)}
</form.Field>
<p className="text-sm text-muted-foreground">Alternative names that users might search for</p>
</div>
{/* Description */}
<form.Field name="description">
{(field) => (
<div className="space-y-3">
<Label htmlFor="description">Description (Optional)</Label>
<Textarea
id="description"
placeholder="Brief description of the icon or service it represents..."
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
rows={3}
/>
<p className="text-sm text-muted-foreground">This helps reviewers understand your submission</p>
</div>
)}
</form.Field>
</div>
{/* Submit Button */}
<div className="flex justify-end gap-4 pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset()
setFilePreviews({})
}}
>
Clear Form
</Button>
<form.Subscribe
selector={(state) => ({
canSubmit: state.canSubmit,
isSubmitting: state.isSubmitting,
})}
>
{(state) => (
<Button type="submit" disabled={!state.canSubmit || state.isSubmitting} size="lg">
{state.isSubmitting ? "Submitting..." : "Submit New Icon"}
</Button>
)}
</form.Subscribe>
</div>
</CardContent>
</Card>
</form>
</div>
)
}

View File

@@ -1,7 +1,5 @@
import { useEffect, useRef } from "react"
export function Carbon() {
// biome-ignore lint/style/noNonNullAssertion: <explanation>
const ref = useRef<HTMLDivElement>(null!)
if (process.env.NODE_ENV === "development") {
return null
}
@@ -16,6 +14,8 @@ export function Carbon() {
ref.current.appendChild(s)
}, [])
const ref = useRef<HTMLDivElement>(null!)
return (
<>
<style>

View File

@@ -1,13 +1,13 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { useMediaQuery } from "@/hooks/use-media-query"
import { filterAndSortIcons, formatIconName, fuzzySearch } from "@/lib/utils"
import type { IconWithName } from "@/types/icons"
import { Info, Search as SearchIcon, Tag } from "lucide-react"
import { useRouter } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
import { useMediaQuery } from "@/hooks/use-media-query"
import { filterAndSortIcons, formatIconName } from "@/lib/utils"
import type { IconWithName } from "@/types/icons"
interface CommandMenuProps {
icons: IconWithName[]
@@ -20,7 +20,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
const router = useRouter()
const [internalOpen, setInternalOpen] = useState(false)
const [query, setQuery] = useState("")
const isDesktop = useMediaQuery("(min-width: 768px)")
const _isDesktop = useMediaQuery("(min-width: 768px)")
// Use either external or internal state for controlling open state
const isOpen = externalOpen !== undefined ? externalOpen : internalOpen
@@ -69,7 +69,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
return (
<CommandDialog open={isOpen} onOpenChange={setIsOpen} contentClassName="bg-background/90 backdrop-blur-sm border border-border/60">
<CommandInput
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
placeholder={`Search our collection of ${totalIcons} icons by name...`}
value={query}
onValueChange={setQuery}
/>

View File

@@ -0,0 +1,426 @@
"use client"
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { IconsGrid } from "@/components/icon-grid"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { filterAndSortIcons, type SortOption } from "@/lib/utils"
import type { IconWithName } from "@/types/icons"
type IconWithStatus = IconWithName & { status: string }
interface CommunityIconSearchProps {
icons: IconWithStatus[]
}
const getStatusColor = (status: string) => {
switch (status) {
case "approved":
return "bg-blue-500/10 text-blue-400 font-bold border-blue-500/20"
case "rejected":
return "bg-red-500/10 text-red-500 border-red-500/20"
case "pending":
return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
case "added_to_collection":
return "bg-green-500/10 text-green-500 border-green-500/20"
default:
return "bg-gray-500/10 text-gray-500 border-gray-500/20"
}
}
const getStatusDisplayName = (status: string) => {
switch (status) {
case "pending":
return "Pending Review"
case "approved":
return "Approved"
case "rejected":
return "Rejected"
case "added_to_collection":
return "Added to Collection"
default:
return status
}
}
export function CommunityIconSearch({ icons }: CommunityIconSearchProps) {
const searchParams = useSearchParams()
const initialQuery = searchParams.get("q")
const initialCategories = searchParams.getAll("category")
const initialSort = (searchParams.get("sort") as SortOption) || "relevance"
const router = useRouter()
const pathname = usePathname()
const [searchQuery, setSearchQuery] = useState(initialQuery ?? "")
const [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? "")
const [selectedCategories, setSelectedCategories] = useState<string[]>(initialCategories ?? [])
const [sortOption, setSortOption] = useState<SortOption>(initialSort)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(searchQuery)
}, 200)
return () => clearTimeout(timer)
}, [searchQuery])
const allCategories = useMemo(() => {
const categories = new Set<string>()
for (const icon of icons) {
for (const category of icon.data.categories) {
categories.add(category)
}
}
return Array.from(categories).sort()
}, [icons])
const matchedAliases = useMemo(() => {
if (!searchQuery.trim()) return {}
const q = searchQuery.toLowerCase()
const matches: Record<string, string> = {}
for (const { name, data } of icons) {
if (!name.toLowerCase().includes(q)) {
const matchingAlias = data.aliases.find((alias) => alias.toLowerCase().includes(q))
if (matchingAlias) {
matches[name] = matchingAlias
}
}
}
return matches
}, [icons, searchQuery])
const filteredIcons = useMemo(() => {
const result = filterAndSortIcons({
icons,
query: debouncedQuery,
categories: selectedCategories,
sort: sortOption,
}) as IconWithStatus[]
return result
}, [icons, debouncedQuery, selectedCategories, sortOption])
const groupedIcons = useMemo(() => {
const statusPriority = { pending: 0, approved: 1, rejected: 2, added_to_collection: 3 }
const groups: Record<string, IconWithStatus[]> = {}
for (const icon of filteredIcons) {
const iconWithStatus = icon as IconWithStatus
const status = iconWithStatus.status || "pending"
if (!groups[status]) {
groups[status] = []
}
groups[status].push(iconWithStatus)
}
return Object.entries(groups)
.sort(([a], [b]) => {
return (statusPriority[a as keyof typeof statusPriority] ?? 999) - (statusPriority[b as keyof typeof statusPriority] ?? 999)
})
.map(([status, items]) => ({ status, items }))
}, [filteredIcons])
const updateResults = useCallback(
(query: string, categories: string[], sort: SortOption) => {
const params = new URLSearchParams()
if (query) params.set("q", query)
for (const category of categories) {
params.append("category", category)
}
if (sort !== "relevance" || initialSort !== "relevance") {
params.set("sort", sort)
}
const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname
router.push(newUrl, { scroll: false })
},
[pathname, router, initialSort],
)
const handleSearch = useCallback(
(query: string) => {
setSearchQuery(query)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
updateResults(query, selectedCategories, sortOption)
}, 200)
},
[updateResults, selectedCategories, sortOption],
)
const handleCategoryChange = useCallback(
(category: string) => {
let newCategories: string[]
if (selectedCategories.includes(category)) {
newCategories = selectedCategories.filter((c) => c !== category)
} else {
newCategories = [...selectedCategories, category]
}
setSelectedCategories(newCategories)
updateResults(searchQuery, newCategories, sortOption)
},
[updateResults, searchQuery, selectedCategories, sortOption],
)
const handleSortChange = useCallback(
(sort: SortOption) => {
setSortOption(sort)
updateResults(searchQuery, selectedCategories, sort)
},
[updateResults, searchQuery, selectedCategories],
)
const clearFilters = useCallback(() => {
setSearchQuery("")
setSelectedCategories([])
setSortOption("relevance")
updateResults("", [], "relevance")
}, [updateResults])
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
if (!searchParams) return null
const getSortLabel = (sort: SortOption) => {
switch (sort) {
case "relevance":
return "Best match"
case "alphabetical-asc":
return "A to Z"
case "alphabetical-desc":
return "Z to A"
case "newest":
return "Newest first"
default:
return "Sort"
}
}
const getSortIcon = (sort: SortOption) => {
switch (sort) {
case "relevance":
return <Search className="h-4 w-4" />
case "alphabetical-asc":
return <ArrowDownAZ className="h-4 w-4" />
case "alphabetical-desc":
return <ArrowUpZA className="h-4 w-4" />
case "newest":
return <Calendar className="h-4 w-4" />
default:
return <SortAsc className="h-4 w-4" />
}
}
return (
<>
<div className="space-y-4 w-full">
<div className="relative w-full">
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground transition-all duration-300">
<Search className="h-4 w-4" />
</div>
<Input
type="search"
placeholder="Search icons by name, alias, or category..."
className="w-full h-10 pl-9 cursor-text transition-all duration-300 text-sm md:text-base border-border shadow-sm"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2 justify-start">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm">
<Filter className="h-4 w-4 mr-2" />
<span>Filter</span>
{selectedCategories.length > 0 && (
<Badge variant="secondary" className="ml-2 px-1.5">
{selectedCategories.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64 sm:w-56">
<DropdownMenuLabel className="font-semibold">Select Categories</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-[40vh] overflow-y-auto p-1">
{allCategories.map((category) => (
<DropdownMenuCheckboxItem
key={category}
checked={selectedCategories.includes(category)}
onCheckedChange={() => handleCategoryChange(category)}
className="cursor-pointer capitalize"
>
{category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</DropdownMenuCheckboxItem>
))}
</div>
{selectedCategories.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setSelectedCategories([])
updateResults(searchQuery, [], sortOption)
}}
className="cursor-pointer focus:bg-rose-50 dark:focus:bg-rose-950/20"
>
Clear categories
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="flex-1 sm:flex-none cursor-pointer bg-background border-border shadow-sm">
{getSortIcon(sortOption)}
<span className="ml-2">{getSortLabel(sortOption)}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="font-semibold">Sort By</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={sortOption} onValueChange={(value) => handleSortChange(value as SortOption)}>
<DropdownMenuRadioItem value="relevance" className="cursor-pointer">
<Search className="h-4 w-4 mr-2" />
Relevance
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="alphabetical-asc" className="cursor-pointer">
<ArrowDownAZ className="h-4 w-4 mr-2" />
Name (A-Z)
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="alphabetical-desc" className="cursor-pointer">
<ArrowUpZA className="h-4 w-4 mr-2" />
Name (Z-A)
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="newest" className="cursor-pointer">
<Calendar className="h-4 w-4 mr-2" />
Newest first
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{(searchQuery || selectedCategories.length > 0 || sortOption !== "relevance") && (
<Button variant="outline" size="sm" onClick={clearFilters} className="flex-1 sm:flex-none cursor-pointer bg-background">
<X className="h-4 w-4 mr-2" />
<span>Reset all</span>
</Button>
)}
</div>
{selectedCategories.length > 0 && (
<div className="flex flex-wrap items-center gap-2 mt-2">
<span className="text-sm text-muted-foreground">Selected:</span>
<div className="flex flex-wrap gap-2">
{selectedCategories.map((category) => (
<Badge key={category} variant="secondary" className="flex items-center gap-1 pl-2 pr-1">
{category.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer"
onClick={() => handleCategoryChange(category)}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedCategories([])
updateResults(searchQuery, [], sortOption)
}}
className="text-xs h-7 px-2 cursor-pointer"
>
Clear
</Button>
</div>
)}
<Separator className="my-2" />
</div>
{filteredIcons.length === 0 ? (
<div className="flex flex-col gap-8 py-12 px-2 w-full max-w-full sm:max-w-2xl mx-auto items-center overflow-x-hidden">
<div className="text-center w-full">
<h2 className="text-3xl sm:text-5xl font-semibold">No icons found</h2>
<p className="text-lg text-muted-foreground mt-2">Try adjusting your search or filters</p>
</div>
</div>
) : (
<div className="space-y-8">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
Found {filteredIcons.length} icon
{filteredIcons.length !== 1 ? "s" : ""}.
</p>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{getSortIcon(sortOption)}
<span>{getSortLabel(sortOption)}</span>
</div>
</div>
{groupedIcons.map(({ status, items }) => (
<section key={status} className="space-y-4">
<div className="flex items-center gap-3">
<Badge variant="outline" className={getStatusColor(status)}>
{getStatusDisplayName(status)}
</Badge>
<span className="text-sm text-muted-foreground">
{items.length} {items.length === 1 ? "icon" : "icons"}
</span>
</div>
<Card className="bg-background/50 border shadow-lg">
<CardContent>
<IconsGrid filteredIcons={items} matchedAliases={matchedAliases} />
</CardContent>
</Card>
</section>
))}
</div>
)}
</>
)
}

View File

@@ -0,0 +1,719 @@
"use client"
import confetti from "canvas-confetti"
import { motion } from "framer-motion"
import {
ArrowRight,
Check,
FileType,
Github,
Moon,
PaletteIcon,
Plus,
Sun,
Type,
Upload,
X,
} from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import type React from "react"
import { useCallback, useState } from "react"
import { toast } from "sonner"
import { IconNameCombobox } from "@/components/icon-name-combobox"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { BASE_URL, REPO_PATH } from "@/constants"
import { formatIconName } from "@/lib/utils"
import { MagicCard } from "./magicui/magic-card"
import { Badge } from "./ui/badge"
import { Dropzone, DropzoneContent, DropzoneEmptyState } from "./ui/shadcn-io/dropzone"
import { pb } from "@/lib/pb"
interface VariantFile {
file: File
preview: string
type: "base" | "light" | "dark" | "wordmark-light" | "wordmark-dark"
label: string
}
interface EditableIconData {
iconName: string
variants: VariantFile[]
categories: string[]
aliases: string[]
description: string
}
const AVAILABLE_CATEGORIES = [
"automation",
"cloud",
"database",
"development",
"entertainment",
"finance",
"gaming",
"home-automation",
"media",
"monitoring",
"network",
"security",
"social",
"storage",
"tools",
"utility",
"other",
]
type AddVariantCardProps = {
onAddVariant: (type: VariantFile["type"], label: string) => void
existingTypes: VariantFile["type"][]
}
function AddVariantCard({ onAddVariant, existingTypes }: AddVariantCardProps) {
const [showOptions, setShowOptions] = useState(false)
const availableVariants = [
{ type: "base" as const, label: "Base Icon", icon: FileType },
{ type: "light" as const, label: "Light Theme", icon: Sun },
{ type: "dark" as const, label: "Dark Theme", icon: Moon },
{ type: "wordmark-light" as const, label: "Wordmark Light", icon: Type },
{ type: "wordmark-dark" as const, label: "Wordmark Dark", icon: Type },
].filter((v) => !existingTypes.includes(v.type))
if (availableVariants.length === 0) return null
return (
<TooltipProvider delayDuration={500}>
<MagicCard className="p-0 rounded-md">
<div className="flex flex-col items-center justify-center p-4 h-full min-h-[280px]">
{!showOptions ? (
<Tooltip>
<TooltipTrigger asChild>
<motion.button
type="button"
className="relative w-28 h-28 mb-3 cursor-pointer rounded-xl overflow-hidden group border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors flex items-center justify-center"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setShowOptions(true)}
aria-label="Add new variant"
>
<Plus className="w-12 h-12 text-muted-foreground group-hover:text-primary transition-colors" />
</motion.button>
</TooltipTrigger>
<TooltipContent>
<p>Add a new variant</p>
</TooltipContent>
</Tooltip>
) : (
<div className="space-y-2 w-full">
<p className="text-sm font-medium text-center mb-3">Select variant type:</p>
{availableVariants.map(({ type, label, icon: Icon }) => (
<Button
key={type}
type="button"
variant="outline"
size="sm"
className="w-full justify-start"
onClick={() => {
onAddVariant(type, label)
setShowOptions(false)
}}
>
<Icon className="w-4 h-4 mr-2" />
{label}
</Button>
))}
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
onClick={() => setShowOptions(false)}
>
Cancel
</Button>
</div>
)}
<p className="text-sm font-medium mt-2">Add Variant</p>
</div>
</MagicCard>
</TooltipProvider>
)
}
type VariantCardProps = {
variant: VariantFile
onRemove: () => void
canRemove: boolean
}
function VariantCard({ variant, onRemove, canRemove }: VariantCardProps) {
return (
<TooltipProvider delayDuration={500}>
<MagicCard className="p-0 rounded-md relative group">
{canRemove && (
<Button
type="button"
variant="destructive"
size="sm"
className="absolute top-2 right-2 z-30 opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 p-0"
onClick={onRemove}
>
<X className="h-4 w-4" />
</Button>
)}
<div className="flex flex-col items-center p-4 transition-all">
<Tooltip>
<TooltipTrigger asChild>
<div className="relative w-28 h-28 mb-3 rounded-xl overflow-hidden">
<div className="absolute inset-0 border-2 border-primary/20 rounded-xl z-10" />
<Image
src={variant.preview}
alt={`${variant.label} preview`}
fill
className="object-contain p-4"
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{variant.label}</p>
</TooltipContent>
</Tooltip>
<p className="text-sm font-medium">{variant.label}</p>
<p className="text-xs text-muted-foreground">
{variant.file.name.split(".").pop()?.toUpperCase()}
</p>
</div>
</MagicCard>
</TooltipProvider>
)
}
type EditableIconDetailsProps = {
onSubmit?: (data: EditableIconData) => void
initialData?: Partial<EditableIconData>
}
export function EditableIconDetails({ onSubmit, initialData }: EditableIconDetailsProps) {
const [iconName, setIconName] = useState(initialData?.iconName || "")
const [variants, setVariants] = useState<VariantFile[]>(initialData?.variants || [])
const [categories, setCategories] = useState<string[]>(initialData?.categories || [])
const [aliases, setAliases] = useState<string[]>(initialData?.aliases || [])
const [aliasInput, setAliasInput] = useState("")
const [description, setDescription] = useState(initialData?.description || "")
const launchConfetti = useCallback((originX?: number, originY?: number) => {
if (typeof confetti !== "function") return
const defaults = {
startVelocity: 15,
spread: 180,
ticks: 50,
zIndex: 20,
disableForReducedMotion: true,
colors: ["#ff0a54", "#ff477e", "#ff7096", "#ff85a1", "#fbb1bd", "#f9bec7"],
}
if (originX !== undefined && originY !== undefined) {
confetti({
...defaults,
particleCount: 50,
origin: {
x: originX / window.innerWidth,
y: originY / window.innerHeight,
},
})
} else {
confetti({
...defaults,
particleCount: 50,
origin: { x: 0.5, y: 0.5 },
})
}
}, [])
const handleAddVariant = async (type: VariantFile["type"], label: string) => {
// Create a file input to get the file
const input = document.createElement("input")
input.type = "file"
input.accept = "image/svg+xml,image/png,image/webp"
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
const preview = await new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target?.result as string)
reader.readAsDataURL(file)
})
setVariants([...variants, { file, preview, type, label }])
launchConfetti()
toast.success("Variant added", {
description: `${label} has been added to your submission`,
})
}
}
input.click()
}
const handleRemoveVariant = (index: number) => {
setVariants(variants.filter((_, i) => i !== index))
toast.info("Variant removed")
}
const toggleCategory = (category: string) => {
setCategories(
categories.includes(category)
? categories.filter((c) => c !== category)
: [...categories, category]
)
}
const handleAddAlias = () => {
const trimmedAlias = aliasInput.trim()
if (trimmedAlias && !aliases.includes(trimmedAlias)) {
setAliases([...aliases, trimmedAlias])
setAliasInput("")
}
}
const handleRemoveAlias = (alias: string) => {
setAliases(aliases.filter((a) => a !== alias))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Validation
if (!iconName.trim()) {
toast.error("Icon name is required")
return
}
if (!/^[a-z0-9-]+$/.test(iconName)) {
toast.error("Icon name must contain only lowercase letters, numbers, and hyphens")
return
}
const baseVariant = variants.find((v) => v.type === "base")
if (!baseVariant) {
toast.error("Base icon variant is required")
return
}
if (categories.length === 0) {
toast.error("At least one category is required")
return
}
if (!pb.authStore.isValid) {
toast.error("You must be logged in to submit an icon")
return
}
try {
// Prepare submission data
const assetFiles: File[] = []
const extras: any = {
aliases,
categories,
base: baseVariant.file.name.split(".").pop() || "svg",
}
// Add base variant
assetFiles.push(baseVariant.file)
// Check for color variants (light/dark)
const lightVariant = variants.find((v) => v.type === "light")
const darkVariant = variants.find((v) => v.type === "dark")
if (lightVariant || darkVariant) {
extras.colors = {}
if (lightVariant) {
extras.colors.light = lightVariant.file.name
assetFiles.push(lightVariant.file)
}
if (darkVariant) {
extras.colors.dark = darkVariant.file.name
assetFiles.push(darkVariant.file)
}
}
// Check for wordmark variants
const wordmarkLight = variants.find((v) => v.type === "wordmark-light")
const wordmarkDark = variants.find((v) => v.type === "wordmark-dark")
if (wordmarkLight || wordmarkDark) {
extras.wordmark = {}
if (wordmarkLight) {
extras.wordmark.light = wordmarkLight.file.name
assetFiles.push(wordmarkLight.file)
}
if (wordmarkDark) {
extras.wordmark.dark = wordmarkDark.file.name
assetFiles.push(wordmarkDark.file)
}
}
// Create submission
const submissionData = {
name: iconName,
assets: assetFiles,
created_by: pb.authStore.model?.id,
status: "pending",
extras: extras,
}
await pb.collection("submissions").create(submissionData)
launchConfetti()
toast.success("Icon submitted!", {
description: `Your icon "${iconName}" has been submitted for review`,
})
// Reset form
setIconName("")
setVariants([])
setCategories([])
setAliases([])
setDescription("")
if (onSubmit) {
onSubmit({ iconName, variants, categories, aliases, description })
}
} catch (error: any) {
console.error("Submission error:", error)
toast.error("Failed to submit icon", {
description: error?.message || "Please try again later",
})
}
}
const getAvailableFormats = () => {
const baseVariant = variants.find((v) => v.type === "base")
if (!baseVariant) return []
const baseFormat = baseVariant.file.name.split(".").pop()?.toLowerCase()
switch (baseFormat) {
case "svg":
return ["svg", "png", "webp"]
case "png":
return ["png", "webp"]
default:
return [baseFormat || ""]
}
}
const availableFormats = getAvailableFormats()
const formattedIconName = iconName ? formatIconName(iconName) : "Your Icon"
const baseVariant = variants.find((v) => v.type === "base")
return (
<form onSubmit={handleSubmit}>
<main className="container mx-auto pt-12 pb-14 px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Left Column - Icon Info & Metadata */}
<div className="lg:col-span-1">
<Card className="h-full bg-background/50 border shadow-lg">
<CardHeader className="pb-4">
<div className="flex flex-col items-center">
{baseVariant ? (
<div className="relative w-32 h-32 rounded-xl overflow-hidden border flex items-center justify-center p-3">
<Image
src={baseVariant.preview}
priority
width={96}
height={96}
alt={`${formattedIconName} icon preview`}
className="w-full h-full object-contain"
/>
</div>
) : (
<div className="relative w-32 h-32 rounded-xl overflow-hidden border border-dashed border-muted-foreground/30 flex items-center justify-center p-3">
<Upload className="w-12 h-12 text-muted-foreground" />
</div>
)}
<div className="w-full mt-4">
<Label htmlFor="icon-name" className="text-sm font-medium mb-2 block">
Icon Name
</Label>
<IconNameCombobox
value={iconName}
onValueChange={setIconName}
/>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Categories */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Categories</h3>
<div className="flex flex-wrap gap-2">
{AVAILABLE_CATEGORIES.map((category) => (
<Badge
key={category}
variant={categories.includes(category) ? "default" : "outline"}
className="cursor-pointer hover:bg-primary/80 text-xs"
onClick={() => toggleCategory(category)}
>
{category
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")}
</Badge>
))}
</div>
{categories.length === 0 && (
<p className="text-xs text-destructive mt-2">At least one category required</p>
)}
</div>
{/* Aliases */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Aliases</h3>
<div className="flex gap-2 mb-2">
<Input
placeholder="Add alias..."
value={aliasInput}
onChange={(e) => setAliasInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handleAddAlias()
}
}}
className="h-8 text-xs"
/>
<Button type="button" size="sm" onClick={handleAddAlias} className="h-8">
<Plus className="h-3 w-3" />
</Button>
</div>
{aliases.length > 0 && (
<div className="flex flex-wrap gap-2">
{aliases.map((alias) => (
<Badge
key={alias}
variant="secondary"
className="inline-flex items-center px-2.5 py-1 text-xs"
>
{alias}
<button
type="button"
onClick={() => handleRemoveAlias(alias)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* About */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
About this icon
</h3>
<div className="text-xs text-muted-foreground space-y-2">
<p>
{variants.length > 0
? `${variants.length} variant${variants.length > 1 ? "s" : ""} uploaded`
: "No variants uploaded yet"}
</p>
{availableFormats.length > 0 && (
<p>
Available in{" "}
{availableFormats.length > 1
? `${availableFormats.length} formats (${availableFormats.map((f) => f.toUpperCase()).join(", ")})`
: `${availableFormats[0].toUpperCase()} format`}
</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Middle Column - Icon Variants */}
<div className="lg:col-span-2">
<Card className="h-full bg-background/50 shadow-lg">
<CardHeader>
<CardTitle>
<h2>Icon Variants</h2>
</CardTitle>
<CardDescription>Add different versions of your icon for various themes</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-10">
{/* Base/Default Variants */}
<div>
<h3 className="text-lg font-semibold flex items-center gap-2 mb-1">
<FileType className="w-4 h-4 text-blue-500" />
Icon Variants
</h3>
<p className="text-sm text-muted-foreground mb-4">
Upload your icon files. Base icon is required.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{variants.map((variant, index) => (
<VariantCard
key={index}
variant={variant}
onRemove={() => handleRemoveVariant(index)}
canRemove={variant.type !== "base" || variants.length > 1}
/>
))}
<AddVariantCard
onAddVariant={handleAddVariant}
existingTypes={variants.map((v) => v.type)}
/>
</div>
</div>
{/* Help Text */}
<div className="bg-muted/50 p-4 rounded-lg space-y-2">
<h4 className="text-sm font-semibold">Variant Types:</h4>
<ul className="text-xs text-muted-foreground space-y-1 list-disc list-inside">
<li>
<strong>Base Icon:</strong> Main icon file (required)
</li>
<li>
<strong>Light Theme:</strong> Optimized for light backgrounds
</li>
<li>
<strong>Dark Theme:</strong> Optimized for dark backgrounds
</li>
<li>
<strong>Wordmark:</strong> Logo with text/brand name
</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Right Column - Technical Details & Actions */}
<div className="lg:col-span-1">
<Card className="h-full bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>Technical Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Base Format</h3>
<div className="flex items-center gap-2">
<FileType className="w-4 h-4 text-blue-500" />
<div className="px-3 py-1.5 border border-border rounded-lg text-sm font-medium">
{baseVariant
? baseVariant.file.name.split(".").pop()?.toUpperCase()
: "N/A"}
</div>
</div>
</div>
{availableFormats.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
Available Formats
</h3>
<div className="flex flex-wrap gap-2">
{availableFormats.map((format) => (
<div
key={format}
className="px-3 py-1.5 border border-border rounded-lg text-xs font-medium"
>
{format.toUpperCase()}
</div>
))}
</div>
</div>
)}
{variants.some((v) => v.type === "light" || v.type === "dark") && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
Color Variants
</h3>
<div className="space-y-2">
{variants
.filter((v) => v.type === "light" || v.type === "dark")
.map((variant, index) => (
<div key={index} className="flex items-center gap-2">
<PaletteIcon className="w-4 h-4 text-purple-500" />
<span className="capitalize font-medium text-sm">
{variant.type}:
</span>
<code className="border border-border px-2 py-0.5 rounded-lg text-xs">
{variant.file.name}
</code>
</div>
))}
</div>
</div>
)}
{variants.some((v) => v.type.startsWith("wordmark")) && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
Wordmark Variants
</h3>
<div className="space-y-2">
{variants
.filter((v) => v.type.startsWith("wordmark"))
.map((variant, index) => (
<div key={index} className="flex items-center gap-2">
<Type className="w-4 h-4 text-green-500" />
<span className="capitalize font-medium text-sm">
{variant.type.replace("wordmark-", "")}:
</span>
<code className="border border-border px-2 py-0.5 rounded-lg text-xs">
{variant.file.name}
</code>
</div>
))}
</div>
</div>
)}
<div className="pt-4 space-y-2">
<Button type="submit" className="w-full" size="lg">
Submit Icon
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
setIconName("")
setVariants([])
setCategories([])
setAliases([])
setDescription("")
}}
>
Clear Form
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</main>
</form>
)
}

View File

@@ -1,6 +1,6 @@
import { REPO_PATH } from "@/constants"
import { ExternalLink } from "lucide-react"
import Link from "next/link"
import { REPO_PATH } from "@/constants"
import { HeartEasterEgg } from "./heart"
export function Footer() {

View File

@@ -1,12 +1,18 @@
"use client"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
export function HeaderNav() {
interface HeaderNavProps {
isLoggedIn?: boolean
}
export function HeaderNav({ isLoggedIn }: HeaderNavProps) {
const pathname = usePathname()
const isIconsActive = pathname === "/icons" || pathname.startsWith("/icons/")
const isCommunityActive = pathname === "/community" || pathname.startsWith("/community/")
const isDashboardActive = pathname === "/dashboard" || pathname.startsWith("/dashboard/")
return (
<nav className="flex flex-row md:items-center items-start gap-4 md:gap-6">
@@ -29,6 +35,27 @@ export function HeaderNav() {
>
Icons
</Link>
<Link
prefetch
href="/community"
className={cn(
"text-sm font-medium transition-colors dark:hover:text-rose-400 cursor-pointer",
isCommunityActive && "text-primary font-semibold",
)}
>
Community
</Link>
{isLoggedIn && (
<Link
href="/dashboard"
className={cn(
"text-sm font-medium transition-colors dark:hover:text-rose-400 cursor-pointer",
isDashboardActive && "text-primary font-semibold",
)}
>
Dashboard
</Link>
)}
</nav>
)
}

View File

@@ -1,22 +1,45 @@
"use client"
import { IconSubmissionForm } from "@/components/icon-submission-form"
import { ThemeSwitcher } from "@/components/theme-switcher"
import { REPO_PATH } from "@/constants"
import { getIconsArray } from "@/lib/api"
import type { IconWithName } from "@/types/icons"
import { Github, PlusCircle, Search } from "lucide-react"
import { Github, LayoutDashboard, LogOut, PlusCircle, Search, Star } from "lucide-react"
import Link from "next/link"
import { useEffect, useState } from "react"
import { usePostHog } from "posthog-js/react"
import { LoginModal } from "@/components/login-modal"
import { ThemeSwitcher } from "@/components/theme-switcher"
import { REPO_NAME, REPO_PATH } from "@/constants"
import { getIconsArray } from "@/lib/api"
import { pb } from "@/lib/pb"
import { resetPostHogIdentity } from "@/lib/posthog-utils"
import type { IconWithName } from "@/types/icons"
import { CommandMenu } from "./command-menu"
import { HeaderNav } from "./header-nav"
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
import { Button } from "./ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
interface UserData {
username: string
email: string
avatar?: string
}
function formatStars(stars: number): string {
if (stars >= 1000) {
return `${(stars / 1000).toFixed(1)}K`
}
return stars.toString()
}
export function Header() {
const [iconsData, setIconsData] = useState<IconWithName[]>([])
const [isLoaded, setIsLoaded] = useState(false)
const [commandMenuOpen, setCommandMenuOpen] = useState(false)
const [loginModalOpen, setLoginModalOpen] = useState(false)
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [userData, setUserData] = useState<UserData | undefined>(undefined)
const [stars, setStars] = useState<number>(0)
const posthog = usePostHog()
useEffect(() => {
async function loadIcons() {
@@ -33,11 +56,75 @@ export function Header() {
loadIcons()
}, [])
// Function to open the command menu
useEffect(() => {
async function fetchStars() {
try {
const response = await fetch(`https://api.github.com/repos/${REPO_NAME}`)
const data = await response.json()
setStars(Math.round(data.stargazers_count / 100) * 100)
} catch (error) {
console.error("Failed to fetch stars:", error)
}
}
fetchStars()
}, [])
useEffect(() => {
const updateAuthState = () => {
if (pb.authStore.isValid && pb.authStore.record) {
setIsLoggedIn(true)
setUserData({
username: pb.authStore.record.username || pb.authStore.record.email,
email: pb.authStore.record.email,
avatar: pb.authStore.record.avatar
? `${pb.baseURL}/api/files/_pb_users_auth_/${pb.authStore.record.id}/${pb.authStore.record.avatar}`
: undefined,
})
} else {
setIsLoggedIn(false)
setUserData(undefined)
}
}
updateAuthState()
const unsubscribe = pb.authStore.onChange(() => {
updateAuthState()
})
return () => {
unsubscribe()
}
}, [])
const openCommandMenu = () => {
setCommandMenuOpen(true)
}
const handleSignOut = () => {
// Track logout event before clearing auth
if (userData) {
posthog?.capture("user_logged_out", {
email: userData.email,
username: userData.username,
})
}
// Clear PocketBase auth
pb.authStore.clear()
// Reset PostHog identity to unlink future events from this user
// This is important for shared computers and follows PostHog best practices
resetPostHogIdentity(posthog)
}
const handleSubmitClick = () => {
if (!isLoggedIn) {
setLoginModalOpen(true)
}
}
return (
<header className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50">
<div className="px-4 md:px-12 flex items-center justify-between h-16 md:h-18">
@@ -46,7 +133,7 @@ export function Header() {
<span className="transition-colors duration-300 group-hover:">Dashboard Icons</span>
</Link>
<div className="flex-nowrap">
<HeaderNav />
<HeaderNav isLoggedIn={isLoggedIn} />
</div>
</div>
<div className="flex items-center gap-2 md:gap-4">
@@ -66,7 +153,7 @@ export function Header() {
<Button
variant="ghost"
size="icon"
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 "
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2"
onClick={openCommandMenu}
>
<Search className="h-5 w-5 transition-all duration-300" />
@@ -74,32 +161,51 @@ export function Header() {
</Button>
</div>
{/* Mobile Submit Button -> triggers IconSubmissionForm dialog */}
{/* Mobile Submit Button */}
<div className="md:hidden">
<IconSubmissionForm
trigger={
<Button variant="ghost" size="icon" className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 ">
{isLoggedIn ? (
<Button variant="ghost" size="icon" className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2" asChild>
<Link href="/submit">
<PlusCircle className="h-5 w-5 transition-all duration-300" />
<span className="sr-only">Submit icon(s)</span>
<span className="sr-only">Submit icon</span>
</Link>
</Button>
}
/>
</div>
<div className="hidden md:flex items-center gap-2 md:gap-4">
{/* Desktop Submit Button */}
<IconSubmissionForm />
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
) : (
<Button
variant="ghost"
size="icon"
className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2"
asChild
onClick={handleSubmitClick}
>
<Link href={REPO_PATH} target="_blank" className="group">
<PlusCircle className="h-5 w-5 transition-all duration-300" />
<span className="sr-only">Submit icon</span>
</Button>
)}
</div>
<div className="hidden md:flex items-center gap-2 md:gap-4">
{isLoggedIn ? (
<Button variant="outline" className="hidden md:inline-flex cursor-pointer transition-all duration-300 items-center gap-2" asChild>
<Link href="/submit">
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Submit icon(s)
</Link>
</Button>
) : (
<Button variant="outline" className="hidden md:inline-flex cursor-pointer transition-all duration-300 items-center gap-2" onClick={handleSubmitClick}>
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Submit icon(s)
</Button>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 gap-1.5" asChild>
<Link href={REPO_PATH} target="_blank" className="group flex items-center">
<Github className="h-5 w-5 group-hover: transition-all duration-300" />
{stars > 0 && (
<>
<span className="text-xs font-medium text-muted-foreground">{formatStars(stars)}</span>
</>
)}
<span className="sr-only">View on GitHub</span>
</Link>
</Button>
@@ -111,11 +217,64 @@ export function Header() {
</TooltipProvider>
</div>
<ThemeSwitcher />
{isLoggedIn && userData && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="transition-colors duration-200 group hover:ring-2 rounded-lg cursor-pointer border border-border/50"
variant="ghost"
size="icon"
>
<Avatar className="h-8 w-8">
<AvatarImage src={userData.avatar || "/placeholder.svg"} alt={userData.username} />
<AvatarFallback className="text-xs">{userData.username.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<span className="sr-only">User menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 p-3">
<div className="space-y-3">
<div className="flex items-center gap-3 px-1">
<Avatar className="h-10 w-10">
<AvatarImage src={userData.avatar || "/placeholder.svg"} alt={userData.username} />
<AvatarFallback className="text-sm font-semibold">{userData.username.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{userData.username}</p>
<p className="text-xs text-muted-foreground truncate">{userData.email}</p>
</div>
</div>
<DropdownMenuSeparator />
<Button asChild variant="ghost" className="w-full justify-start gap-2 hover:bg-muted">
<Link href="/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
</Button>
<Button
onClick={handleSignOut}
variant="ghost"
className="w-full justify-start gap-2 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<LogOut className="h-4 w-4" />
Sign out
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Single instance of CommandMenu */}
{isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />}
{/* Login Modal */}
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
</header>
)
}

View File

@@ -1,8 +1,7 @@
"use client"
import { Heart } from "lucide-react"
import { motion } from "framer-motion"
import { Heart } from "lucide-react"
import { useState } from "react"
export function HeartEasterEgg() {

View File

@@ -1,9 +1,5 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { Separator } from "@radix-ui/react-dropdown-menu"
import { motion, useAnimation, useInView } from "framer-motion"
import {
@@ -25,6 +21,10 @@ import {
} from "lucide-react"
import Link from "next/link"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { AuroraText } from "./magicui/aurora-text"
import { InteractiveHoverButton } from "./magicui/interactive-hover-button"
import { NumberTicker } from "./magicui/number-ticker"
@@ -35,7 +35,7 @@ interface IconCardProps {
imageUrl: string
}
function IconCard({ name, imageUrl }: IconCardProps) {
function _IconCard({ name, imageUrl }: IconCardProps) {
return (
<Card className="p-4 flex flex-col items-center gap-2 cursor-pointer group hover-lift card-hover">
<div className="w-16 h-16 flex items-center justify-center">
@@ -52,7 +52,7 @@ function ElegantShape({
width = 400,
height = 100,
rotate = 0,
gradient = "from-rose-500/[0.5]",
gradient = "from-primary/[0.5]",
mobileWidth,
mobileHeight,
}: {
@@ -128,10 +128,13 @@ function ElegantShape({
<div
className={cn(
"absolute inset-0 rounded-full",
"bg-gradient-to-r from-rose-500/[0.6] via-rose-500/[0.4] to-rose-500/[0.1]",
// Use primary
"bg-gradient-to-r from-primary/[0.6] via-primary/[0.4] to-primary/[0.1]",
gradient,
"backdrop-blur-[3px]",
"shadow-[0_0_40px_0_rgba(244,63,94,0.35),inset_0_0_0_1px_rgba(244,63,94,0.2)]",
"shadow-primary/35",
"inset-shadow-2xs",
"inset-shadow-primary/20",
"after:absolute after:inset-0 after:rounded-full",
"after:bg-[radial-gradient(circle_at_50%_50%,rgba(255,255,255,0.4),transparent_70%)]",
)}
@@ -146,7 +149,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
return (
<div className="relative w-full flex items-center justify-center overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-rose-500/[0.1] via-transparent to-rose-500/[0.1] blur-3xl" />
<div className="absolute inset-0 bg-gradient-to-br from-primary/[0.1] via-transparent to-primary/[0.1] blur-3xl" />
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<ElegantShape
@@ -156,7 +159,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
mobileWidth={300}
mobileHeight={80}
rotate={12}
gradient="from-rose-500/[0.6]"
gradient="from-primary/[0.6]"
className="left-[-10%] md:left-[-5%] top-[15%] md:top-[20%]"
/>
@@ -167,7 +170,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
mobileWidth={250}
mobileHeight={70}
rotate={-15}
gradient="from-rose-500/[0.55]"
gradient="from-primary/[0.55]"
className="right-[-5%] md:right-[0%] top-[70%] md:top-[75%]"
/>
@@ -178,7 +181,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
mobileWidth={150}
mobileHeight={50}
rotate={-8}
gradient="from-rose-500/[0.65]"
gradient="from-primary/[0.65]"
className="left-[5%] md:left-[10%] bottom-[5%] md:bottom-[10%]"
/>
@@ -189,7 +192,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
mobileWidth={100}
mobileHeight={40}
rotate={20}
gradient="from-rose-500/[0.58]"
gradient="from-primary/[0.58]"
className="right-[15%] md:right-[20%] top-[10%] md:top-[15%]"
/>
@@ -200,7 +203,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
mobileWidth={80}
mobileHeight={30}
rotate={-25}
gradient="from-rose-500/[0.62]"
gradient="from-primary/[0.62]"
className="left-[20%] md:left-[25%] top-[5%] md:top-[10%]"
/>
</div>
@@ -209,13 +212,13 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
<div className="max-w-4xl mx-auto text-center flex flex-col gap-4 ">
<h1 className="relative text-3xl sm:text-5xl md:text-7xl font-bold mb-4 md:mb-8 tracking-tight motion-preset-slide-up motion-duration-500 ">
Your definitive source for
<Sparkles className="absolute -right-1 -bottom-3 text-rose-500 h-8 w-8 sm:h-12 sm:w-12 md:h-16 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[-120%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-500 motion-delay-[0.13s]/scale motion-duration-[0.13s]/opacity motion-duration-[0.40s]/rotate motion-duration-[0.05s]/blur motion-delay-[0.20s]/blur motion-ease-spring-bouncier" />
<Sparkles className="absolute -right-1 -bottom-3 text-primary h-8 w-8 sm:h-12 sm:w-12 md:h-16 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[-120%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-500 motion-delay-[0.13s]/scale motion-duration-[0.13s]/opacity motion-duration-[0.40s]/rotate motion-duration-[0.05s]/blur motion-delay-[0.20s]/blur motion-ease-spring-bouncier" />
<br />
<Sparkles className="absolute -left-1 -top-3 text-rose-500 h-5 w-5 sm:h-8 sm:w-8 md:h-12 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[159%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-500 motion-delay-[0.13s]/scale motion-duration-[0.13s]/opacity motion-duration-[0.40s]/rotate motion-duration-[0.05s]/blur motion-delay-[0.20s]/blur motion-ease-spring-bouncier" />
<Sparkles className="absolute -left-1 -top-3 text-primary h-5 w-5 sm:h-8 sm:w-8 md:h-12 md:w-12 motion-delay-300 motion-preset-seesaw-lg motion-scale-in-[0.5] motion-translate-x-in-[159%] motion-translate-y-in-[-60%] motion-opacity-in-[33%] motion-rotate-in-[-1080deg] motion-blur-in-[10px] motion-duration-500 motion-delay-[0.13s]/scale motion-duration-[0.13s]/opacity motion-duration-[0.40s]/rotate motion-duration-[0.05s]/blur motion-delay-[0.20s]/blur motion-ease-spring-bouncier" />
<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText>
</h1>
<p className="text-sm sm:text-base md:text-xl text-muted-foreground leading-relaxed mb-8 font-light tracking-wide max-w-2xl mx-auto px-4 motion-preset-slide-down motion-duration-500">
<p className="text-sm sm:text-base md:text-xl text-muted-foreground leading-relaxed mb-8 font-medium tracking-wide max-w-2xl mx-auto px-4 motion-preset-slide-down motion-duration-500">
A collection of{" "}
<NumberTicker value={totalIcons} startValue={1000} className="font-bold tracking-tighter text-muted-foreground" /> curated icons
for services, applications and tools, designed specifically for dashboards and app directories.
@@ -274,8 +277,8 @@ export default function GiveUsAStarButton({ stars }: { stars: string | number })
</div>
<div className="space-y-2">
<h5 className="text-sm font-medium text-secondary-foreground">How your star helps us:</h5>
<ul className="text-xs text-secondary-foreground/80 space-y-1.5">
<h5 className="text-sm font-medium text-muted-foreground">How your star helps us:</h5>
<ul className="text-xs text-muted-foreground/80 space-y-1.5">
<li className="flex items-start gap-2">
<TrendingUp className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>Increases our visibility in GitHub search results</span>
@@ -307,7 +310,7 @@ export default function GiveUsAStarButton({ stars }: { stars: string | number })
<Button
variant="link"
size="sm"
className="flex items-center gap-1 text-xs text-secondary-foreground"
className="flex items-center gap-1 text-xs text-muted-foreground"
onClick={() =>
window.open("https://docs.github.com/get-started/exploring-projects-on-github/saving-repositories-with-stars", "_blank")
}
@@ -329,7 +332,7 @@ export function GiveUsLoveButton() {
<Button variant="outline" className="h-9 md:h-10 px-4 cursor-pointer">
<div className="flex items-center gap-2">
<p>Give us love</p>
<Heart className="h-4 w-4 ml-1 fill-red-500 text-red-500" />
<Heart className="h-4 w-4 ml-1 fill-primary text-primary" />
</div>
</Button>
</HoverCardTrigger>
@@ -337,7 +340,7 @@ export function GiveUsLoveButton() {
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none flex items-center gap-2">
<Heart className="h-4 w-4 fill-red-500 text-red-500" />
<Heart className="h-4 w-4 fill-primary text-primary" />
Support us without spending
</h4>
<p className="text-sm text-muted-foreground">We keep our service free through minimal, non-intrusive ads.</p>
@@ -357,8 +360,8 @@ export function GiveUsLoveButton() {
</div>
<div className="space-y-2">
<h5 className="text-sm font-medium text-secondary-foreground">Our Privacy Promise:</h5>
<ul className="text-xs text-secondary-foreground/80 space-y-1.5">
<h5 className="text-sm font-medium text-muted-foreground">Our Privacy Promise:</h5>
<ul className="text-xs text-muted-foreground/80 space-y-1.5">
<li className="flex items-start gap-2">
<span className="text-primary font-bold">✓</span>
<span>We don't track your browsing habits</span>
@@ -377,11 +380,11 @@ export function GiveUsLoveButton() {
<Separator className="bg-secondary/20" />
<div className="space-y-2">
<h5 className="text-sm font-medium text-secondary-foreground flex items-center gap-2">
<h5 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Share2 className="h-4 w-4 text-primary" />
Spread the word
</h5>
<p className="text-xs text-secondary-foreground/80">
<p className="text-xs text-muted-foreground/80">
Don't want to disable your ad blocker? You can still help us by sharing our website with others who might find it useful.
</p>
</div>
@@ -425,8 +428,8 @@ export function GiveUsMoneyButton() {
</div>
<div className="space-y-2">
<h5 className="text-sm font-medium text-secondary-foreground">Where your money goes:</h5>
<ul className="text-xs text-secondary-foreground/80 space-y-1.5">
<h5 className="text-sm font-medium text-muted-foreground">Where your money goes:</h5>
<ul className="text-xs text-muted-foreground/80 space-y-1.5">
<li className="flex items-start gap-2">
<Server className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
<span>Hosting and infrastructure costs</span>
@@ -453,7 +456,7 @@ export function GiveUsMoneyButton() {
</Button>
</Link>
<Link href={`${openCollectiveUrl}/transactions`} target="_blank" rel="noopener noreferrer">
<Button variant="link" size="sm" className="flex items-center gap-1 text-xs text-secondary-foreground">
<Button variant="link" size="sm" className="flex items-center gap-1 text-xs text-muted-foreground">
View transactions
<ExternalLink className="h-3 w-3" />
</Button>

View File

@@ -1,21 +1,21 @@
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Check, Copy, Download, Github, Link as LinkIcon } from "lucide-react";
import Link from "next/link";
import type React from "react";
import { Check, Copy, Download, Github, Link as LinkIcon } from "lucide-react"
import Link from "next/link"
import type React from "react"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
export type IconActionsProps = {
imageUrl: string;
githubUrl: string;
iconName: string;
format: string;
variantKey: string;
copiedUrlKey: string | null;
copiedImageKey: string | null;
handleDownload: (event: React.MouseEvent, url: string, filename: string) => Promise<void>;
handleCopyUrl: (url: string, variantKey: string, event?: React.MouseEvent) => void;
handleCopyImage: (imageUrl: string, format: string, variantKey: string, event?: React.MouseEvent) => Promise<void>;
};
imageUrl: string
githubUrl: string
iconName: string
format: string
variantKey: string
copiedUrlKey: string | null
copiedImageKey: string | null
handleDownload: (event: React.MouseEvent, url: string, filename: string) => Promise<void>
handleCopyUrl: (url: string, variantKey: string, event?: React.MouseEvent) => void
handleCopyImage: (imageUrl: string, format: string, variantKey: string, event?: React.MouseEvent) => Promise<void>
}
export function IconActions({
imageUrl,
@@ -29,9 +29,9 @@ export function IconActions({
handleCopyUrl,
handleCopyImage,
}: IconActionsProps) {
const downloadFilename = `${iconName}.${format}`;
const isUrlCopied = copiedUrlKey === variantKey;
const isImageCopied = copiedImageKey === variantKey;
const downloadFilename = `${iconName}.${format}`
const isUrlCopied = copiedUrlKey === variantKey
const isImageCopied = copiedImageKey === variantKey
return (
<TooltipProvider delayDuration={300}>
@@ -64,11 +64,7 @@ export function IconActions({
onClick={(e) => handleCopyImage(imageUrl, format, variantKey, e)}
aria-label={`Copy ${iconName} image as ${format.toUpperCase()}`}
>
{isImageCopied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
{isImageCopied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -86,11 +82,7 @@ export function IconActions({
onClick={(e) => handleCopyUrl(imageUrl, variantKey, e)}
aria-label={`Copy direct URL for ${iconName} ${format.toUpperCase()}`}
>
{isUrlCopied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<LinkIcon className="w-4 h-4" />
)}
{isUrlCopied ? <Check className="w-4 h-4 text-green-500" /> : <LinkIcon className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -102,12 +94,7 @@ export function IconActions({
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
<Link
href={githubUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`View ${iconName} ${format} file on GitHub`}
>
<Link href={githubUrl} target="_blank" rel="noopener noreferrer" aria-label={`View ${iconName} ${format} file on GitHub`}>
<Github className="w-4 h-4" />
</Link>
</Button>
@@ -118,5 +105,5 @@ export function IconActions({
</Tooltip>
</div>
</TooltipProvider>
);
)
}

View File

@@ -1,26 +1,23 @@
import Image from "next/image"
import Link from "next/link"
import { MagicCard } from "@/components/magicui/magic-card"
import { BASE_URL } from "@/constants"
import { formatIconName } from "@/lib/utils"
import type { Icon } from "@/types/icons"
import Image from "next/image"
import Link from "next/link"
export function IconCard({
name,
data: iconData,
matchedAlias,
}: {
name: string
data: Icon
matchedAlias?: string
}) {
export function IconCard({ name, data: iconData, matchedAlias }: { name: string; data: Icon; matchedAlias?: string }) {
const formatedIconName = formatIconName(name)
const isCommunityIcon = iconData.base.startsWith("http")
const imageUrl = isCommunityIcon ? iconData.base : `${BASE_URL}/${iconData.base}/${iconData.colors?.light || name}.${iconData.base}`
const linkHref = isCommunityIcon ? `/community/${name}` : `/icons/${name}`
return (
<MagicCard className="rounded-md shadow-md">
<Link prefetch={false} href={`/icons/${name}`} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer">
<Link prefetch={false} href={linkHref} className="group flex flex-col items-center p-3 sm:p-4 cursor-pointer">
<div className="relative h-16 w-16 mb-2">
<Image
src={`${BASE_URL}/${iconData.base}/${iconData.colors?.light || name}.${iconData.base}`}
src={imageUrl}
alt={`${name} icon`}
fill
className="object-contain p-1 group-hover:scale-110 transition-transform duration-300"

View File

@@ -1,13 +1,5 @@
"use client"
import { IconsGrid } from "@/components/icon-grid"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { BASE_URL, REPO_PATH } from "@/constants"
import { formatIconName } from "@/lib/utils"
import type { AuthorData, Icon, IconFile } from "@/types/icons"
import confetti from "canvas-confetti"
import { motion } from "framer-motion"
import { ArrowRight, Check, FileType, Github, Moon, PaletteIcon, Sun, Type } from "lucide-react"
@@ -16,16 +8,20 @@ import Link from "next/link"
import type React from "react"
import { useCallback, useState } from "react"
import { toast } from "sonner"
import { IconsGrid } from "@/components/icon-grid"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { BASE_URL, REPO_PATH } from "@/constants"
import { formatIconName } from "@/lib/utils"
import type { AuthorData, Icon, IconFile } from "@/types/icons"
import { Carbon } from "./carbon"
import { IconActions } from "./icon-actions"
import { MagicCard } from "./magicui/magic-card"
import { Badge } from "./ui/badge"
type RenderVariantFn = (
format: string,
iconName: string,
theme?: "light" | "dark"
) => React.ReactNode
type RenderVariantFn = (format: string, iconName: string, theme?: "light" | "dark") => React.ReactNode
type IconVariantsSectionProps = {
title: string
@@ -76,11 +72,7 @@ type WordmarkSectionProps = {
renderVariant: RenderVariantFn
}
function WordmarkSection({
iconData,
aavailableFormats,
renderVariant,
}: WordmarkSectionProps) {
function WordmarkSection({ iconData, aavailableFormats, renderVariant }: WordmarkSectionProps) {
if (!iconData.wordmark) return null
return (
@@ -89,9 +81,7 @@ function WordmarkSection({
<Type className="w-4 h-4 text-green-500" />
Wordmark Variants
</h3>
<p className="text-sm text-muted-foreground mb-4">
Icon variants that include the brand name. Click to copy URL.
</p>
<p className="text-sm text-muted-foreground mb-4">Icon variants that include the brand name. Click to copy URL.</p>
<div className="space-y-6">
{iconData.wordmark.light && (
<div>
@@ -135,8 +125,8 @@ export type IconDetailsProps = {
export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetailsProps) {
const authorName = authorData.name || authorData.login || ""
const iconColorVariants = iconData.colors
const iconWordmarkVariants = iconData.wordmark
const _iconColorVariants = iconData.colors
const _iconWordmarkVariants = iconData.wordmark
const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
@@ -155,7 +145,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
}
const availableFormats = getAvailableFormats()
const [copiedVariants, setCopiedVariants] = useState<Record<string, boolean>>({})
const [copiedVariants, _setCopiedVariants] = useState<Record<string, boolean>>({})
const [copiedUrlKey, setCopiedUrlKey] = useState<string | null>(null)
const [copiedImageKey, setCopiedImageKey] = useState<string | null>(null)
@@ -207,16 +197,11 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
})
}
const handleCopyImage = async (
imageUrl: string,
format: string,
variantKey: string,
event?: React.MouseEvent
) => {
const handleCopyImage = async (imageUrl: string, format: string, variantKey: string, event?: React.MouseEvent) => {
try {
toast.loading("Copying image...")
if (format === 'svg') {
if (format === "svg") {
const response = await fetch(imageUrl)
if (!response.ok) {
throw new Error(`Failed to fetch SVG: ${response.statusText}`)
@@ -240,8 +225,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
toast.success("SVG Markup Copied", {
description: "The SVG code has been copied to your clipboard.",
})
} else if (format === 'png' || format === 'webp') {
} else if (format === "png" || format === "webp") {
const mimeType = `image/${format}`
const response = await fetch(imageUrl)
if (!response.ok) {
@@ -250,10 +234,10 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
const blob = await response.blob()
if (!blob) {
throw new Error('Failed to generate image blob')
throw new Error("Failed to generate image blob")
}
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })]);
await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })])
setCopiedImageKey(variantKey)
setTimeout(() => {
@@ -270,11 +254,9 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
toast.success("Image copied", {
description: `The ${format.toUpperCase()} image has been copied to your clipboard.`,
})
} else {
throw new Error(`Unsupported format for image copy: ${format}`)
}
} catch (error) {
console.error("Copy error:", error)
toast.dismiss()
@@ -445,9 +427,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
{authorName}
</Link>
)}
{!authorData.html_url && (
<span className="text-sm">{authorName}</span>
)}
{!authorData.html_url && <span className="text-sm">{authorName}</span>}
</div>
</div>
</div>
@@ -696,6 +676,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
<Card className="bg-background/50 border shadow-lg">
<CardHeader>
<CardTitle>
{/** biome-ignore lint/correctness/useUniqueElementIds: I want the ID to be fixed */}
<h2 id="related-icons-title">Related Icons</h2>
</CardTitle>
<CardDescription>

View File

@@ -1,7 +1,6 @@
import type { Icon } from "@/types/icons"
import { useWindowVirtualizer } from "@tanstack/react-virtual"
import { useEffect, useMemo, useRef, useState } from "react"
import type { Icon } from "@/types/icons"
import { IconCard } from "./icon-card"
interface IconsGridProps {
@@ -9,9 +8,19 @@ interface IconsGridProps {
matchedAliases: Record<string, string>
}
/**
* Base grid layout component used for both regular icon display and virtualized display
* Displays icons in a responsive grid with different column counts per breakpoint
*/
export const GRID_CLASSES = "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4"
/**
* Simple non-virtualized grid for displaying icons
* Used for smaller lists (e.g., related icons, first 120 results)
*/
export function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 mt-2">
<div className={`${GRID_CLASSES} mt-2`}>
{filteredIcons.slice(0, 120).map(({ name, data }) => (
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name]} />
))}
@@ -19,6 +28,10 @@ export function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
)
}
/**
* Virtualized grid for displaying large lists of icons efficiently
* Only renders visible rows for better performance with thousands of icons
*/
export function VirtualizedIconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
const listRef = useRef<HTMLDivElement | null>(null)
const [windowWidth, setWindowWidth] = useState(0)
@@ -73,7 +86,7 @@ export function VirtualizedIconsGrid({ filteredIcons, matchedAliases }: IconsGri
width: "100%",
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
}}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4"
className={GRID_CLASSES}
>
{rowIcons.map(({ name, data }) => (
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name]} />

View File

@@ -0,0 +1,124 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import { useExistingIconNames } from "@/hooks/use-submissions"
interface IconNameComboboxProps {
value: string
onValueChange: (value: string) => void
error?: string
isInvalid?: boolean
}
export function IconNameCombobox({ value, onValueChange, error, isInvalid }: IconNameComboboxProps) {
const { data: existingIcons = [], isLoading: loading } = useExistingIconNames()
const [isFocused, setIsFocused] = useState(false)
const [rawInput, setRawInput] = useState(value)
const sanitizeIconName = (input: string): string => {
return input
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value
setRawInput(raw) // Track raw input for immediate filtering
const sanitized = sanitizeIconName(raw)
onValueChange(sanitized)
}
// Filter existing icons based on EITHER raw input OR sanitized value - show ALL matches
const filteredIcons = useMemo(() => {
const searchTerm = rawInput || value
if (!searchTerm || !existingIcons.length) return []
const lowerSearch = searchTerm.toLowerCase()
return existingIcons.filter((icon) =>
icon.value.toLowerCase().includes(lowerSearch) ||
icon.label.toLowerCase().includes(lowerSearch)
)
}, [rawInput, value, existingIcons])
const showSuggestions = isFocused && (rawInput || value) && filteredIcons.length > 0
// Sync rawInput with external value changes (form reset, etc.)
useEffect(() => {
if (!isFocused) {
setRawInput(value)
}
}, [value, isFocused])
return (
<div className="relative w-full">
<Input
type="text"
value={rawInput}
onChange={handleInputChange}
onFocus={() => setIsFocused(true)}
onBlur={() => {
// Sync with sanitized value when leaving input
setRawInput(value)
// Delay to allow clicking on suggestions
setTimeout(() => setIsFocused(false), 200)
}}
placeholder="Type new icon ID (e.g., my-app)..."
className={cn(
"font-mono",
isInvalid && "border-destructive focus-visible:ring-destructive/50"
)}
aria-invalid={isInvalid}
aria-describedby={error ? "icon-name-error" : undefined}
/>
{/* Inline suggestions list */}
{showSuggestions && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 rounded-md border bg-popover shadow-md">
<Command className="rounded-md">
<CommandList className="max-h-[300px] overflow-y-auto">
<CommandEmpty>No existing icons found</CommandEmpty>
<CommandGroup heading={`⚠️ Existing Icons (${filteredIcons.length} matches - Not Allowed)`}>
{filteredIcons.slice(0, 50).map((icon) => (
<CommandItem
key={icon.value}
value={icon.value}
onSelect={(selectedValue) => {
setRawInput(selectedValue)
onValueChange(selectedValue)
setIsFocused(false)
}}
className="cursor-pointer opacity-60"
>
<AlertCircle className="h-3.5 w-3.5 text-destructive mr-2 flex-shrink-0" />
<span className="font-mono text-sm">{icon.label}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
{/* Error message */}
{error && isInvalid && (
<p id="icon-name-error" className="text-sm text-destructive mt-1.5 flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5 flex-shrink-0" />
<span>{error}</span>
</p>
)}
{/* Helper text when no error */}
{!error && value && (
<p className="text-sm text-muted-foreground mt-1.5">
{loading ? "Checking availability..." : "✓ Available icon ID"}
</p>
)}
</div>
)
}

View File

@@ -1,5 +1,11 @@
"use client"
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import { useTheme } from "next-themes"
import posthog from "posthog-js"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
import { VirtualizedIconsGrid } from "@/components/icon-grid"
import { IconSubmissionContent } from "@/components/icon-submission-form"
import { Badge } from "@/components/ui/badge"
@@ -17,14 +23,8 @@ import {
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { type SortOption, filterAndSortIcons } from "@/lib/utils"
import { filterAndSortIcons, type SortOption } from "@/lib/utils"
import type { IconSearchProps } from "@/types/icons"
import { ArrowDownAZ, ArrowUpZA, Calendar, Filter, Search, SortAsc, X } from "lucide-react"
import { useTheme } from "next-themes"
import { usePathname, useRouter, useSearchParams } from "next/navigation"
import posthog from "posthog-js"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { toast } from "sonner"
export function IconSearch({ icons }: IconSearchProps) {
const searchParams = useSearchParams()
@@ -359,6 +359,7 @@ export function IconSearch({ icons }: IconSearchProps) {
<p className="text-lg text-muted-foreground mt-2">Help us expand our collection</p>
</div>
<div className="flex flex-col gap-4 items-center w-full">
{/** biome-ignore lint/correctness/useUniqueElementIds: I want the ID to be fixed */}
<div id="icon-submission-content" className="w-full">
<IconSubmissionContent />
</div>
@@ -387,7 +388,7 @@ export function IconSearch({ icons }: IconSearchProps) {
</div>
) : (
<>
<div className="flex justify-between items-center pb-2">
<div className="flex justify-between items-center">
<p className="text-sm text-muted-foreground">
Found {filteredIcons.length} icon
{filteredIcons.length !== 1 ? "s" : ""}.

View File

@@ -1,12 +1,13 @@
"use client"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { REPO_PATH } from "@/constants"
import { DialogDescription } from "@radix-ui/react-dialog"
import { ExternalLink, PlusCircle } from "lucide-react"
import Link from "next/link"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Dropzone, DropzoneContent, DropzoneEmptyState } from "@/components/ui/shadcn-io/dropzone"
import { REPO_PATH } from "@/constants"
export const ISSUE_TEMPLATES = [
{
@@ -41,8 +42,62 @@ export const ISSUE_TEMPLATES = [
},
]
export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
const [files, setFiles] = useState<File[] | undefined>()
const [filePreview, setFilePreview] = useState<string | undefined>()
const handleDrop = (files: File[]) => {
console.log(files)
setFiles(files)
if (files.length > 0) {
const reader = new FileReader()
reader.onload = (e) => {
if (typeof e.target?.result === 'string') {
setFilePreview(e.target?.result)
}
}
reader.readAsDataURL(files[0])
}
}
return (
<div className="flex flex-col gap-4">
{/* Dropzone Section */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Upload Icon Files</h3>
<Dropzone
accept={{ 'image/*': ['.png', '.jpg', '.jpeg', '.svg', '.webp'] }}
onDrop={handleDrop}
onError={console.error}
src={files}
maxFiles={5}
maxSize={1024 * 1024 * 5}
>
<DropzoneEmptyState />
<DropzoneContent>
{filePreview && (
<div className="h-[102px] w-full">
<img
alt="Preview"
className="absolute top-0 left-0 h-full w-full object-cover"
src={filePreview}
/>
</div>
)}
</DropzoneContent>
</Dropzone>
{files && files.length > 0 && (
<div className="text-xs text-muted-foreground">
{files.length} file(s) selected
</div>
)}
</div>
{/* Divider */}
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground mb-3">Or submit via GitHub issue:</p>
</div>
{/* Issue Templates */}
<div className="flex flex-col gap-2">
{ISSUE_TEMPLATES.map((template) => (
<Link key={template.id} href={template.url} className="w-full group z10" target="_blank" rel="noopener noreferrer">
@@ -66,7 +121,7 @@ export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
</div>
)
}
export function IconSubmissionForm({ trigger }: { trigger?: React.ReactNode }) {
export function IconSubmissionForm({ trigger, onClick }: { trigger?: React.ReactNode, onClick?: () => void }) {
const [open, setOpen] = useState(false)
return (
@@ -74,7 +129,7 @@ export function IconSubmissionForm({ trigger }: { trigger?: React.ReactNode }) {
{trigger ? (
<DialogTrigger asChild>{trigger}</DialogTrigger>
) : (
<DialogTrigger asChild>
<DialogTrigger asChild onClick={onClick}>
<Button variant="outline" className="hidden md:inline-flex cursor-pointer transition-all duration-300 items-center gap-2">
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Submit icon(s)
</Button>

View File

@@ -1,11 +1,11 @@
"use client"
import { Button } from "@/components/ui/button"
import { REPO_PATH } from "@/constants"
import { AnimatePresence, motion } from "framer-motion"
import { X } from "lucide-react"
import Link from "next/link"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
import { REPO_PATH } from "@/constants"
const LOCAL_STORAGE_KEY = "licenseNoticeDismissed"

View File

@@ -0,0 +1,277 @@
"use client"
import { Github, Loader2 } from "lucide-react"
import type React from "react"
import { useRef, useState } from "react"
import { usePostHog } from "posthog-js/react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { pb } from "@/lib/pb"
import { identifyUserInPostHog } from "@/lib/posthog-utils"
interface LoginModalProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function LoginModal({ open, onOpenChange }: LoginModalProps) {
const [isRegister, setIsRegister] = useState(false)
const [email, setEmail] = useState("")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const emailRef = useRef<HTMLInputElement>(null)
const posthog = usePostHog()
const resetForm = () => {
setEmail("")
setUsername("")
setPassword("")
setConfirmPassword("")
setError("")
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsLoading(true)
try {
if (isRegister) {
// Validation
if (password !== confirmPassword) {
setError("Passwords do not match")
return
}
if (!username.trim()) {
setError("Username is required")
return
}
if (!email.trim()) {
setError("Email is required")
return
}
// Create account and login
await pb.collection("users").create({
username: username.trim(),
email: email.trim(),
password,
passwordConfirm: confirmPassword,
})
await pb.collection("users").authWithPassword(email, password)
// Identify user immediately after successful authentication
// This follows PostHog best practice of calling identify as soon as possible
identifyUserInPostHog(posthog)
// Track registration event
posthog?.capture("user_registered", {
email: email.trim(),
username: username.trim(),
})
} else {
// Login
await pb.collection("users").authWithPassword(email, password)
// Identify user immediately after successful authentication
// This follows PostHog best practice of calling identify as soon as possible
identifyUserInPostHog(posthog)
// Track login event
posthog?.capture("user_logged_in", {
email: email.trim(),
})
}
// Success
onOpenChange(false)
resetForm()
} catch (err: any) {
console.error("Auth error:", err)
setError(err?.message || "Authentication failed. Please try again.")
} finally {
setIsLoading(false)
}
}
const toggleMode = () => {
setIsRegister(!isRegister)
resetForm()
setTimeout(() => emailRef.current?.focus(), 100)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full max-w-lg bg-background border shadow-2xl">
<DialogHeader className="text-center space-y-2 pb-4">
<DialogTitle className="text-3xl font-bold">
{isRegister ? "Create Account" : "Welcome Back"}
</DialogTitle>
<DialogDescription className="text-lg text-muted-foreground">
{isRegister
? "Join our community and start submitting icons"
: "Sign in to submit and manage your icons"
}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Error Message */}
{error && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive px-4 py-3 rounded-lg flex items-center gap-3">
<svg className="h-5 w-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
<span className="font-medium">{error}</span>
</div>
)}
{/* GitHub Button (Coming Soon) */}
<Button
type="button"
variant="outline"
className="w-full h-12 text-base font-medium cursor-not-allowed opacity-50"
disabled
>
<Github className="h-5 w-5 mr-2" />
Continue with GitHub
<span className="ml-2 text-xs text-muted-foreground">(Coming soon)</span>
</Button>
{/* Divider */}
<div className="relative">
<Separator />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-background px-4 text-sm text-muted-foreground font-medium">
or continue with email
</span>
</div>
{/* Form Fields */}
<div className="space-y-4">
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-semibold">
Email {!isRegister && "or Username"}
</Label>
<Input
id="email"
ref={emailRef}
autoFocus
type="text"
autoComplete="username"
placeholder={`Enter your email${isRegister ? "" : " or username"}`}
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-12 text-base"
required
/>
{isRegister && (
<p className="text-xs text-muted-foreground">
We'll only use this to send you updates about your submissions
</p>
)}
</div>
{/* Username Field (Register only) */}
{isRegister && (
<div className="space-y-2">
<Label htmlFor="username" className="text-sm font-semibold">
Username
</Label>
<Input
id="username"
type="text"
autoComplete="username"
placeholder="Choose a username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="h-12 text-base"
required
/>
<p className="text-xs text-muted-foreground">
This will be displayed publicly with your submissions
</p>
</div>
)}
{/* Password Field */}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-semibold">
Password
</Label>
<Input
id="password"
type="password"
autoComplete={isRegister ? "new-password" : "current-password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-12 text-base"
required
/>
</div>
{/* Confirm Password Field (Register only) */}
{isRegister && (
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-semibold">
Confirm Password
</Label>
<Input
id="confirmPassword"
type="password"
autoComplete="new-password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-12 text-base"
required
/>
</div>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
className="w-full h-12 text-base font-semibold"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
Please wait...
</>
) : (
<>{isRegister ? "Create Account" : "Sign In"}</>
)}
</Button>
{/* Toggle Mode */}
<div className="text-center">
<button
type="button"
onClick={toggleMode}
className="text-sm text-muted-foreground hover:text-foreground transition-colors font-medium hover:underline underline-offset-4"
>
{isRegister
? "Already have an account? Sign in"
: "Don't have an account? Create one"
}
</button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,6 +1,6 @@
import { cn } from "@/lib/utils"
import { ArrowRight } from "lucide-react"
import React from "react"
import { cn } from "@/lib/utils"
import { Button } from "../ui/button"
interface InteractiveHoverButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

View File

@@ -2,9 +2,10 @@
import { motion, useMotionTemplate, useMotionValue } from "motion/react"
import type React from "react"
import { useCallback, useEffect, useRef } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { cn } from "@/lib/utils"
import { useTheme } from "next-themes"
interface MagicCardProps {
children?: React.ReactNode
@@ -29,6 +30,7 @@ export function MagicCard({
const mouseX = useMotionValue(-gradientSize)
const mouseY = useMotionValue(-gradientSize)
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (cardRef.current) {
@@ -76,6 +78,21 @@ export function MagicCard({
mouseY.set(-gradientSize)
}, [gradientSize, mouseX, mouseY])
const { theme } = useTheme() // "light" | "dark"
const [fromColor, setFromColor] = useState(gradientFrom)
const [toColor, setToColor] = useState(gradientTo)
useEffect(() => {
if (theme === "dark") {
setFromColor("#ffb3c1") // fallback for dark
setToColor("#ff75a0")
} else {
setFromColor("#1e9df1") // fallback for light
setToColor("#8ed0f9")
}
}, [theme])
return (
<div ref={cardRef} className={cn("group relative rounded-[inherit]", className)}>
<motion.div
@@ -83,8 +100,8 @@ export function MagicCard({
style={{
background: useMotionTemplate`
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
${gradientFrom},
${gradientTo},
${fromColor},
${toColor},
var(--border) 100%
)
`,

View File

@@ -1,5 +1,5 @@
import { cn } from "@/lib/utils"
import type { ComponentPropsWithoutRef } from "react"
import { cn } from "@/lib/utils"
interface MarqueeProps extends ComponentPropsWithoutRef<"div"> {
/**

View File

@@ -0,0 +1,26 @@
"use client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { useState } from "react"
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
},
},
}),
)
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
{children}
</QueryClientProvider>
)
}

View File

@@ -1,13 +1,13 @@
"use client"
import { Marquee } from "@/components/magicui/marquee"
import { BASE_URL } from "@/constants"
import { cn, formatIconName } from "@/lib/utils"
import type { Icon, IconWithName } from "@/types/icons"
import { format, isToday, isYesterday } from "date-fns"
import { ArrowRight, Clock, ExternalLink } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { Marquee } from "@/components/magicui/marquee"
import { BASE_URL } from "@/constants"
import { cn, formatIconName } from "@/lib/utils"
import type { Icon, IconWithName } from "@/types/icons"
function formatIconDate(timestamp: string): string {
const date = new Date(timestamp)
@@ -32,7 +32,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
<div className="mx-auto px-6 lg:px-8">
<div className="mx-auto max-w-2xl text-center my-4">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl bg-clip-text text-transparent bg-gradient-to-r from-rose-600 to-rose-500 motion-safe:motion-preset-fade-lg motion-duration-500">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary motion-safe:motion-preset-fade-lg motion-duration-500">
Recently Added Icons
</h2>
</div>
@@ -71,13 +71,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
}
// Marquee-compatible icon card
function RecentIconCard({
name,
data,
}: {
name: string
data: Icon
}) {
function RecentIconCard({ name, data }: { name: string; data: Icon }) {
const formattedIconName = formatIconName(name)
return (
<Link
@@ -85,12 +79,12 @@ function RecentIconCard({
href={`/icons/${name}`}
className={cn(
"flex flex-col items-center p-3 sm:p-4 rounded-xl border border-border",
"transition-all duration-300 hover:shadow-lg hover:shadow-rose-500/5 relative overflow-hidden hover-lift",
"transition-all duration-300 hover:shadow-lg hover:shadow-primary/5 relative overflow-hidden hover-lift",
"w-36 mx-2 group/item",
)}
aria-label={`View details for ${formattedIconName} icon`}
>
<div className="absolute inset-0 bg-gradient-to-br from-rose-500/5 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300" />
<div className="absolute inset-0 bg-gradient-to-b from-primary/15 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300" />
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
<Image
@@ -100,7 +94,7 @@ function RecentIconCard({
className="object-contain p-1 hover:scale-110 transition-transform duration-300"
/>
</div>
<span className="text-xs sm:text-sm text-center truncate w-full capitalize dark:hover:text-rose-400 transition-colors duration-200 font-medium">
<span className="text-xs sm:text-sm text-center truncate w-full capitalize dark:hover:text-primary transition-colors duration-200 font-medium">
{formattedIconName}
</span>
<div className="flex items-center justify-center mt-2 w-full">

View File

@@ -0,0 +1,404 @@
"use client"
import { Calendar, Check, Download, ExternalLink, FileType, FolderOpen, Palette, Tag, User as UserIcon, X } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { IconCard } from "@/components/icon-card"
import { MagicCard } from "@/components/magicui/magic-card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { UserDisplay } from "@/components/user-display"
import { pb, type Submission, type User } from "@/lib/pb"
import { formatIconName } from "@/lib/utils"
import type { Icon } from "@/types/icons"
// Utility function to get display name with priority: username > email > created_by field
const getDisplayName = (submission: Submission, expandedData?: { created_by: User; approved_by: User }): string => {
// Check if we have expanded user data
if (expandedData && expandedData.created_by) {
const user = expandedData.created_by
// Priority: username > email
if (user.username) {
return user.username
}
if (user.email) {
return user.email
}
}
// Fallback to created_by field (could be user ID or username)
return submission.created_by
}
interface SubmissionDetailsProps {
submission: Submission
isAdmin: boolean
onUserClick?: (userId: string, displayName: string) => void
onApprove?: () => void
onReject?: () => void
isApproving?: boolean
isRejecting?: boolean
}
export function SubmissionDetails({
submission,
isAdmin,
onUserClick,
onApprove,
onReject,
isApproving,
isRejecting,
}: SubmissionDetailsProps) {
const expandedData = submission.expand
const displayName = getDisplayName(submission, expandedData)
// Sanitize extras to ensure we have safe defaults
const sanitizedExtras = {
base: submission.extras?.base || "svg",
aliases: submission.extras?.aliases || [],
categories: submission.extras?.categories || [],
colors: submission.extras?.colors || null,
wordmark: submission.extras?.wordmark || null,
}
const formattedCreated = new Date(submission.created).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
const formattedUpdated = new Date(submission.updated).toLocaleDateString("en-GB", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
// Create a mock Icon object for the IconCard component
const mockIconData: Icon = {
base: sanitizedExtras.base,
aliases: sanitizedExtras.aliases,
categories: sanitizedExtras.categories,
update: {
timestamp: submission.updated,
author: {
id: 1,
name: displayName,
},
},
colors: sanitizedExtras.colors
? {
dark: sanitizedExtras.colors.dark,
light: sanitizedExtras.colors.light,
}
: undefined,
wordmark: sanitizedExtras.wordmark
? {
dark: sanitizedExtras.wordmark.dark,
light: sanitizedExtras.wordmark.light,
}
: undefined,
}
const handleDownload = async (url: string, filename: string) => {
try {
const response = await fetch(url)
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = blobUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
setTimeout(() => URL.revokeObjectURL(blobUrl), 100)
} catch (error) {
console.error("Download error:", error)
}
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Assets Preview */}
<div className="lg:col-span-1">
<Card className="h-full bg-background/50 border">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<FileType className="w-5 h-5" />
Assets Preview
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-4">
{submission.assets.map((asset, index) => (
<MagicCard key={index} className="p-0 rounded-md">
<div className="relative">
<div className="aspect-square rounded-lg border flex items-center justify-center p-8 bg-muted/30">
<Image
src={`${pb.baseURL}/api/files/submissions/${submission.id}/${asset}` || "/placeholder.svg"}
alt={`${submission.name} asset ${index + 1}`}
width={200}
height={200}
className="max-w-full max-h-full object-contain"
/>
</div>
<div className="absolute top-2 right-2 flex gap-1">
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation()
window.open(`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`, "_blank")
}}
>
<ExternalLink className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation()
handleDownload(
`${pb.baseUrl}/api/files/submissions/${submission.id}/${asset}`,
`${submission.name}-${index + 1}.${sanitizedExtras.base}`,
)
}}
>
<Download className="h-3 w-3" />
</Button>
</div>
</div>
</MagicCard>
))}
{submission.assets.length === 0 && <div className="text-center py-8 text-muted-foreground text-sm">No assets available</div>}
</div>
</CardContent>
</Card>
</div>
{/* Middle Column - Submission Details */}
<div className="lg:col-span-2">
<Card className="h-full bg-background/50 border">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Tag className="w-5 h-5" />
Submission Details
</CardTitle>
{(onApprove || onReject) && (
<div className="flex gap-2">
{onApprove && (
<Button
size="sm"
color="green"
variant="outline"
onClick={(e) => {
e.stopPropagation()
onApprove()
}}
disabled={isApproving || isRejecting}
>
<Check className="w-4 h-4 mr-2" />
{isApproving ? "Approving..." : "Approve"}
</Button>
)}
{onReject && (
<Button
size="sm"
color="red"
variant="destructive"
onClick={(e) => {
e.stopPropagation()
onReject()
}}
disabled={isApproving || isRejecting}
>
<X className="w-4 h-4 mr-2" />
{isRejecting ? "Rejecting..." : "Reject"}
</Button>
)}
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-6">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<FileType className="w-4 h-4" />
Icon Name
</h3>
<p className="text-lg font-medium capitalize">{formatIconName(submission.name)}</p>
<p className="text-sm text-muted-foreground mt-1">Filename: {submission.name}</p>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<FileType className="w-4 h-4" />
Base Format
</h3>
<Badge variant="outline" className="uppercase font-mono">
{sanitizedExtras.base}
</Badge>
</div>
{sanitizedExtras.colors && Object.keys(sanitizedExtras.colors).length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<Palette className="w-4 h-4" />
Color Variants
</h3>
<div className="space-y-2">
{sanitizedExtras.colors.dark && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground min-w-12">Dark:</span>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.colors.dark}</code>
</div>
)}
{sanitizedExtras.colors.light && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground min-w-12">Light:</span>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.colors.light}</code>
</div>
)}
</div>
</div>
)}
{sanitizedExtras.wordmark && Object.keys(sanitizedExtras.wordmark).length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<FileType className="w-4 h-4" />
Wordmark Variants
</h3>
<div className="space-y-2">
{sanitizedExtras.wordmark.dark && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground min-w-12">Dark:</span>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.wordmark.dark}</code>
</div>
)}
{sanitizedExtras.wordmark.light && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground min-w-12">Light:</span>
<code className="text-xs bg-muted px-2 py-1 rounded font-mono">{sanitizedExtras.wordmark.light}</code>
</div>
)}
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<UserIcon className="w-4 h-4" />
Submitted By
</h3>
<UserDisplay
userId={submission.created_by}
avatar={expandedData.created_by.avatar}
displayName={displayName}
onClick={onUserClick}
size="md"
/>
</div>
{submission.approved_by && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<UserIcon className="w-4 h-4" />
{submission.status === "approved" ? "Approved By" : "Reviewed By"}
</h3>
<UserDisplay
userId={expandedData.approved_by.id}
displayName={expandedData.approved_by.username}
avatar={expandedData.approved_by.avatar}
size="md"
/>
</div>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4" />
Created
</h3>
<p className="text-sm">{formattedCreated}</p>
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4" />
Last Updated
</h3>
<p className="text-sm">{formattedUpdated}</p>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<FolderOpen className="w-4 h-4" />
Categories
</h3>
{sanitizedExtras.categories.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sanitizedExtras.categories.map((category) => (
<Badge key={category} variant="outline" className="border-primary/20 hover:border-primary">
{category
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No categories assigned</p>
)}
</div>
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-3 flex items-center gap-2">
<Tag className="w-4 h-4" />
Aliases
</h3>
{sanitizedExtras.aliases.length > 0 ? (
<div className="flex flex-wrap gap-2">
{sanitizedExtras.aliases.map((alias) => (
<Badge key={alias} variant="outline" className="text-xs font-mono">
{alias}
</Badge>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No aliases assigned</p>
)}
</div>
</div>
{isAdmin && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Submission ID</h3>
<code className="bg-muted px-2 py-1 rounded block break-all font-mono">{submission.id}</code>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,441 @@
"use client"
import {
type ColumnDef,
type ColumnFiltersState,
type ExpandedState,
flexRender,
getCoreRowModel,
getExpandedRowModel,
getFilteredRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table"
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import { Check, ChevronDown, ChevronRight, Filter, ImageIcon, Search, SortDesc, X } from "lucide-react"
import * as React from "react"
import { SubmissionDetails } from "@/components/submission-details"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { UserDisplay } from "@/components/user-display"
import { pb, type Submission } from "@/lib/pb"
import { cn } from "@/lib/utils"
// Initialize dayjs relative time plugin
dayjs.extend(relativeTime)
// Utility function to get display name with priority: username > email > created_by field
const getDisplayName = (submission: Submission, expandedData?: any): string => {
// Check if we have expanded user data
if (expandedData && expandedData.created_by) {
const user = expandedData.created_by
// Priority: username > email
if (user.username) {
return user.username
}
if (user.email) {
return user.email
}
}
// Fallback to created_by field (could be user ID or username)
return submission.created_by
}
interface SubmissionsDataTableProps {
data: Submission[]
isAdmin: boolean
currentUserId: string
onApprove: (id: string) => void
onReject: (id: string) => void
isApproving?: boolean
isRejecting?: boolean
}
// Group submissions by status with priority order
const groupAndSortSubmissions = (submissions: Submission[]): Submission[] => {
const statusPriority = { pending: 0, approved: 1, added_to_collection: 2, rejected: 3 }
return [...submissions].sort((a, b) => {
// First, sort by status priority
const statusDiff = statusPriority[a.status] - statusPriority[b.status]
if (statusDiff !== 0) return statusDiff
// Within same status, sort by updated time (most recent first)
return new Date(b.updated).getTime() - new Date(a.updated).getTime()
})
}
const getStatusColor = (status: Submission["status"]) => {
switch (status) {
case "approved":
return "bg-blue-500/10 text-blue-400 font-bold border-blue-500/20"
case "rejected":
return "bg-red-500/10 text-red-500 border-red-500/20"
case "pending":
return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
case "added_to_collection":
return "bg-green-500/10 text-green-500 border-green-500/20"
default:
return "bg-gray-500/10 text-gray-500 border-gray-500/20"
}
}
const getStatusDisplayName = (status: Submission["status"]) => {
switch (status) {
case "pending":
return "Pending"
case "approved":
return "Approved"
case "rejected":
return "Rejected"
case "added_to_collection":
return "Added to Collection"
default:
return status
}
}
export function SubmissionsDataTable({
data,
isAdmin,
currentUserId,
onApprove,
onReject,
isApproving,
isRejecting,
}: SubmissionsDataTableProps) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [expanded, setExpanded] = React.useState<ExpandedState>({})
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [globalFilter, setGlobalFilter] = React.useState("")
const [userFilter, setUserFilter] = React.useState<{ userId: string; displayName: string } | null>(null)
// Handle row expansion - only one row can be expanded at a time
const handleRowToggle = React.useCallback((rowId: string, isExpanded: boolean) => {
setExpanded(isExpanded ? {} : { [rowId]: true })
}, [])
// Group and sort data by status and updated time
const groupedData = React.useMemo(() => {
return groupAndSortSubmissions(data)
}, [data])
// Handle user filter - filter by user ID but display username
const handleUserFilter = React.useCallback(
(userId: string, displayName: string) => {
if (userFilter?.userId === userId) {
setUserFilter(null)
setColumnFilters((prev) => prev.filter((filter) => filter.id !== "created_by"))
} else {
setUserFilter({ userId, displayName })
setColumnFilters((prev) => [...prev.filter((filter) => filter.id !== "created_by"), { id: "created_by", value: userId }])
}
},
[userFilter],
)
const columns: ColumnDef<Submission>[] = React.useMemo(() => [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return (
<button
onClick={(e) => {
e.stopPropagation()
handleRowToggle(row.id, row.getIsExpanded())
}}
className="flex items-center justify-center w-8 h-8 hover:bg-muted rounded transition-colors"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
)
},
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Name
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => <div className="font-medium capitalize">{row.getValue("name")}</div>,
},
{
accessorKey: "status",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Status
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const status = row.getValue("status") as Submission["status"]
return (
<Badge variant="outline" className={getStatusColor(status)}>
{getStatusDisplayName(status)}
</Badge>
)
},
},
{
accessorKey: "created_by",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Submitted By
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const submission = row.original
const expandedData = (submission as any).expand
const displayName = getDisplayName(submission, expandedData)
const userId = submission.created_by
return (
<div className="flex items-center gap-1">
<UserDisplay
userId={userId}
avatar={expandedData.created_by.avatar}
displayName={displayName}
onClick={handleUserFilter}
size="md"
/>
{userFilter?.userId === userId && <X className="h-3 w-3 text-muted-foreground" />}
</div>
)
},
},
{
accessorKey: "updated",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
className="h-auto p-0 font-semibold hover:bg-transparent"
>
Updated
<SortDesc className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const date = row.getValue("updated") as string
return (
<div className="text-sm text-muted-foreground" title={dayjs(date).format("MMMM D, YYYY h:mm A")}>
{dayjs(date).fromNow()}
</div>
)
},
},
{
accessorKey: "assets",
header: "Preview",
cell: ({ row }) => {
const assets = row.getValue("assets") as string[]
const name = row.getValue("name") as string
if (assets.length > 0) {
return (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-background p-2">
<img
src={`${pb.baseUrl}/api/files/submissions/${row.original.id}/${assets[0]}?thumb=100x100` || "/placeholder.svg"}
alt={name}
className="w-full h-full object-contain"
/>
</div>
)
}
return (
<div className="w-12 h-12 rounded border flex items-center justify-center bg-muted">
<ImageIcon className="w-6 h-6 text-muted-foreground" />
</div>
)
},
},
], [handleRowToggle, handleUserFilter, userFilter])
const table = useReactTable({
data: groupedData,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onExpandedChange: setExpanded,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
state: {
sorting,
expanded,
columnFilters,
globalFilter,
},
getRowCanExpand: () => true,
globalFilterFn: (row, columnId, value) => {
const searchValue = value.toLowerCase()
const name = row.getValue("name") as string
const status = row.getValue("status") as string
const submission = row.original
const expandedData = (submission as any).expand
const displayName = getDisplayName(submission, expandedData)
return (
name.toLowerCase().includes(searchValue) ||
status.toLowerCase().includes(searchValue) ||
displayName.toLowerCase().includes(searchValue)
)
},
})
return (
<div className="space-y-4">
{/* Search and Filters */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1 border rounded-md bg-background">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search submissions..."
autoFocus
value={globalFilter ?? ""}
onChange={(event) => setGlobalFilter(String(event.target.value))}
className="pl-10"
/>
</div>
{userFilter && (
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<Badge variant="secondary" className="gap-1">
User: {userFilter.displayName}
<Button
variant="ghost"
size="sm"
className="h-auto p-0 hover:bg-transparent"
onClick={() => {
setUserFilter(null)
setColumnFilters((prev) => prev.filter((filter) => filter.id !== "created_by"))
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
</div>
)}
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody className="bg-background">
{table.getRowModel().rows?.length ? (
(() => {
let lastStatus: string | null = null
return table.getRowModel().rows.map((row, index) => {
const currentStatus = row.original.status
const showStatusHeader = currentStatus !== lastStatus
lastStatus = currentStatus
return (
<React.Fragment key={row.id}>
{showStatusHeader && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={columns.length} className="py-2 font-semibold text-sm">
<div className="flex items-center gap-2">
<Badge variant="outline" className={getStatusColor(currentStatus)}>
{getStatusDisplayName(currentStatus)}
</Badge>
<span className="text-xs text-muted-foreground">
{table.getRowModel().rows.filter((r) => r.original.status === currentStatus).length}
{table.getRowModel().rows.filter((r) => r.original.status === currentStatus).length === 1
? " submission"
: " submissions"}
</span>
</div>
</TableCell>
</TableRow>
)}
<TableRow
data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer hover:bg-muted/50 transition-colors", row.getIsExpanded() && "bg-muted/30")}
onClick={() => handleRowToggle(row.id, row.getIsExpanded())}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
{row.getIsExpanded() && (
<TableRow>
<TableCell colSpan={columns.length} className="p-6 bg-muted/20 border-t">
<SubmissionDetails
submission={row.original}
isAdmin={isAdmin}
onUserClick={handleUserFilter}
onApprove={row.original.status === "pending" && isAdmin ? () => onApprove(row.original.id) : undefined}
onReject={row.original.status === "pending" && isAdmin ? () => onReject(row.original.id) : undefined}
isApproving={isApproving}
isRejecting={isRejecting}
/>
</TableCell>
</TableRow>
)}
</React.Fragment>
)
})
})()
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
{globalFilter || userFilter ? "No submissions found matching your search" : "No submissions found"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -2,11 +2,10 @@
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { useState } from "react"
export function ThemeSwitcher() {
const { setTheme } = useTheme()

View File

@@ -0,0 +1,107 @@
"use client"
import { motion, MotionStyle, Transition } from "motion/react"
import { cn } from "@/lib/utils"
interface BorderBeamProps {
/**
* The size of the border beam.
*/
size?: number
/**
* The duration of the border beam.
*/
duration?: number
/**
* The delay of the border beam.
*/
delay?: number
/**
* The color of the border beam from.
*/
colorFrom?: string
/**
* The color of the border beam to.
*/
colorTo?: string
/**
* The motion transition of the border beam.
*/
transition?: Transition
/**
* The class name of the border beam.
*/
className?: string
/**
* The style of the border beam.
*/
style?: React.CSSProperties
/**
* Whether to reverse the animation direction.
*/
reverse?: boolean
/**
* The initial offset position (0-100).
*/
initialOffset?: number
/**
* The border width of the beam.
*/
borderWidth?: number
}
export const BorderBeam = ({
className,
size = 50,
delay = 0,
duration = 6,
colorFrom = "#ffaa40",
colorTo = "#9c40ff",
transition,
style,
reverse = false,
initialOffset = 0,
borderWidth = 1,
}: BorderBeamProps) => {
return (
<div
className="pointer-events-none absolute inset-0 rounded-[inherit] border-(length:--border-beam-width) border-transparent [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)] [mask-composite:intersect] [mask-clip:padding-box,border-box]"
style={
{
"--border-beam-width": `${borderWidth}px`,
} as React.CSSProperties
}
>
<motion.div
className={cn(
"absolute aspect-square",
"bg-gradient-to-l from-[var(--color-from)] via-[var(--color-to)] to-transparent",
className
)}
style={
{
width: size,
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
"--color-from": colorFrom,
"--color-to": colorTo,
...style,
} as MotionStyle
}
initial={{ offsetDistance: `${initialOffset}%` }}
animate={{
offsetDistance: reverse
? [`${100 - initialOffset}%`, `${-initialOffset}%`]
: [`${initialOffset}%`, `${100 + initialOffset}%`],
}}
transition={{
repeat: Infinity,
ease: "linear",
duration,
delay: -delay,
...transition,
}}
/>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-300 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 [&_svg]:transition-all [&_svg]:duration-300 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:scale-[1.02]",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-300 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 [&_svg]:transition-all [&_svg]:duration-300 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:scale-[1.02] cursor-pointer",
{
variants: {
variant: {

View File

@@ -68,7 +68,7 @@ function CommandInput({
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
"placeholder:text-muted-foreground flex w-full rounded-md bg-transparent py-1 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
@@ -38,7 +38,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"backdrop-blur-xs data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
@@ -49,24 +49,32 @@ function DialogOverlay({
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
" data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
@@ -131,6 +139,5 @@ export {
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
DialogTrigger,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,311 @@
'use client';
import { useControllableState } from '@radix-ui/react-use-controllable-state';
import { ChevronsUpDownIcon, PlusIcon } from 'lucide-react';
import {
type ComponentProps,
createContext,
type ReactNode,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
type ComboboxData = {
label: string;
value: string;
};
type ComboboxContextType = {
data: ComboboxData[];
type: string;
value: string;
onValueChange: (value: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
width: number;
setWidth: (width: number) => void;
inputValue: string;
setInputValue: (value: string) => void;
};
const ComboboxContext = createContext<ComboboxContextType>({
data: [],
type: 'item',
value: '',
onValueChange: () => {},
open: false,
onOpenChange: () => {},
width: 200,
setWidth: () => {},
inputValue: '',
setInputValue: () => {},
});
export type ComboboxProps = ComponentProps<typeof Popover> & {
data: ComboboxData[];
type: string;
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const Combobox = ({
data,
type,
defaultValue,
value: controlledValue,
onValueChange: controlledOnValueChange,
defaultOpen = false,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
...props
}: ComboboxProps) => {
const [value, onValueChange] = useControllableState({
defaultProp: defaultValue ?? '',
prop: controlledValue,
onChange: controlledOnValueChange,
});
const [open, onOpenChange] = useControllableState({
defaultProp: defaultOpen,
prop: controlledOpen,
onChange: controlledOnOpenChange,
});
const [width, setWidth] = useState(200);
const [inputValue, setInputValue] = useState('');
return (
<ComboboxContext.Provider
value={{
type,
value,
onValueChange,
open,
onOpenChange,
data,
width,
setWidth,
inputValue,
setInputValue,
}}
>
<Popover {...props} onOpenChange={onOpenChange} open={open} />
</ComboboxContext.Provider>
);
};
export type ComboboxTriggerProps = ComponentProps<typeof Button>;
export const ComboboxTrigger = ({
children,
...props
}: ComboboxTriggerProps) => {
const { value, data, type, setWidth } = useContext(ComboboxContext);
const ref = useRef<HTMLButtonElement>(null);
useEffect(() => {
// Create a ResizeObserver to detect width changes
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newWidth = (entry.target as HTMLElement).offsetWidth;
if (newWidth) {
setWidth?.(newWidth);
}
}
});
if (ref.current) {
resizeObserver.observe(ref.current);
}
// Clean up the observer when component unmounts
return () => {
resizeObserver.disconnect();
};
}, [setWidth]);
return (
<PopoverTrigger asChild>
<Button variant="outline" {...props} ref={ref}>
{children ?? (
<span className="flex w-full items-center justify-between gap-2">
{value
? data.find((item) => item.value === value)?.label
: `Select ${type}...`}
<ChevronsUpDownIcon
className="shrink-0 text-muted-foreground"
size={16}
/>
</span>
)}
</Button>
</PopoverTrigger>
);
};
export type ComboboxContentProps = ComponentProps<typeof Command> & {
popoverOptions?: ComponentProps<typeof PopoverContent>;
};
export const ComboboxContent = ({
className,
popoverOptions,
...props
}: ComboboxContentProps) => {
const { width } = useContext(ComboboxContext);
return (
<PopoverContent
className={cn('p-0', className)}
style={{ width }}
{...popoverOptions}
>
<Command {...props} />
</PopoverContent>
);
};
export type ComboboxInputProps = ComponentProps<typeof CommandInput> & {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
};
export const ComboboxInput = ({
value: controlledValue,
defaultValue,
onValueChange: controlledOnValueChange,
...props
}: ComboboxInputProps) => {
const { type, inputValue, setInputValue } = useContext(ComboboxContext);
const [value, onValueChange] = useControllableState({
defaultProp: defaultValue ?? inputValue,
prop: controlledValue,
onChange: (newValue) => {
// Sync with context state
setInputValue(newValue);
// Call external onChange if provided
controlledOnValueChange?.(newValue);
},
});
return (
<CommandInput
onValueChange={onValueChange}
placeholder={`Search ${type}...`}
value={value}
{...props}
/>
);
};
export type ComboboxListProps = ComponentProps<typeof CommandList>;
export const ComboboxList = (props: ComboboxListProps) => (
<CommandList {...props} />
);
export type ComboboxEmptyProps = ComponentProps<typeof CommandEmpty>;
export const ComboboxEmpty = ({ children, ...props }: ComboboxEmptyProps) => {
const { type } = useContext(ComboboxContext);
return (
<CommandEmpty {...props}>{children ?? `No ${type} found.`}</CommandEmpty>
);
};
export type ComboboxGroupProps = ComponentProps<typeof CommandGroup>;
export const ComboboxGroup = (props: ComboboxGroupProps) => (
<CommandGroup {...props} />
);
export type ComboboxItemProps = ComponentProps<typeof CommandItem>;
export const ComboboxItem = (props: ComboboxItemProps) => {
const { onValueChange, onOpenChange } = useContext(ComboboxContext);
return (
<CommandItem
onSelect={(currentValue) => {
onValueChange(currentValue);
onOpenChange(false);
}}
{...props}
/>
);
};
export type ComboboxSeparatorProps = ComponentProps<typeof CommandSeparator>;
export const ComboboxSeparator = (props: ComboboxSeparatorProps) => (
<CommandSeparator {...props} />
);
export type ComboboxCreateNewProps = {
onCreateNew: (value: string) => void;
children?: (inputValue: string) => ReactNode;
className?: string;
};
export const ComboboxCreateNew = ({
onCreateNew,
children,
className,
}: ComboboxCreateNewProps) => {
const { inputValue, type, onValueChange, onOpenChange } =
useContext(ComboboxContext);
if (!inputValue.trim()) {
return null;
}
const handleCreateNew = () => {
onCreateNew(inputValue.trim());
onValueChange(inputValue.trim());
onOpenChange(false);
};
return (
<button
className={cn(
'relative flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
onClick={handleCreateNew}
type="button"
>
{children ? (
children(inputValue)
) : (
<>
<PlusIcon className="h-4 w-4 text-muted-foreground" />
<span>
Create new {type}: "{inputValue}"
</span>
</>
)}
</button>
);
};

View File

@@ -0,0 +1,202 @@
'use client';
import { UploadIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import { createContext, useContext } from 'react';
import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type DropzoneContextType = {
src?: File[];
accept?: DropzoneOptions['accept'];
maxSize?: DropzoneOptions['maxSize'];
minSize?: DropzoneOptions['minSize'];
maxFiles?: DropzoneOptions['maxFiles'];
};
const renderBytes = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)}${units[unitIndex]}`;
};
const DropzoneContext = createContext<DropzoneContextType | undefined>(
undefined
);
export type DropzoneProps = Omit<DropzoneOptions, 'onDrop'> & {
src?: File[];
className?: string;
onDrop?: (
acceptedFiles: File[],
fileRejections: FileRejection[],
event: DropEvent
) => void;
children?: ReactNode;
};
export const Dropzone = ({
accept,
maxFiles = 1,
maxSize,
minSize,
onDrop,
onError,
disabled,
src,
className,
children,
...props
}: DropzoneProps) => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept,
maxFiles,
maxSize,
minSize,
onError,
disabled,
onDrop: (acceptedFiles, fileRejections, event) => {
if (fileRejections.length > 0) {
const message = fileRejections.at(0)?.errors.at(0)?.message;
onError?.(new Error(message));
return;
}
onDrop?.(acceptedFiles, fileRejections, event);
},
...props,
});
return (
<DropzoneContext.Provider
key={JSON.stringify(src)}
value={{ src, accept, maxSize, minSize, maxFiles }}
>
<Button
className={cn(
'relative h-auto w-full flex-col overflow-hidden p-8',
isDragActive && 'outline-none ring-1 ring-ring',
className
)}
disabled={disabled}
type="button"
variant="outline"
{...getRootProps()}
>
<input {...getInputProps()} disabled={disabled} />
{children}
</Button>
</DropzoneContext.Provider>
);
};
const useDropzoneContext = () => {
const context = useContext(DropzoneContext);
if (!context) {
throw new Error('useDropzoneContext must be used within a Dropzone');
}
return context;
};
export type DropzoneContentProps = {
children?: ReactNode;
className?: string;
};
const maxLabelItems = 3;
export const DropzoneContent = ({
children,
className,
}: DropzoneContentProps) => {
const { src } = useDropzoneContext();
if (!src) {
return null;
}
if (children) {
return children;
}
return (
<div className={cn('flex flex-col items-center justify-center', className)}>
<div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
<UploadIcon size={16} />
</div>
<p className="my-2 w-full truncate font-medium text-sm">
{src.length > maxLabelItems
? `${new Intl.ListFormat('en').format(
src.slice(0, maxLabelItems).map((file) => file.name)
)} and ${src.length - maxLabelItems} more`
: new Intl.ListFormat('en').format(src.map((file) => file.name))}
</p>
<p className="w-full text-wrap text-muted-foreground text-xs">
Drag and drop or click to replace
</p>
</div>
);
};
export type DropzoneEmptyStateProps = {
children?: ReactNode;
className?: string;
};
export const DropzoneEmptyState = ({
children,
className,
}: DropzoneEmptyStateProps) => {
const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext();
if (src) {
return null;
}
if (children) {
return children;
}
let caption = '';
if (accept) {
caption += 'Accepts ';
caption += new Intl.ListFormat('en').format(Object.keys(accept));
}
if (minSize && maxSize) {
caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`;
} else if (minSize) {
caption += ` at least ${renderBytes(minSize)}`;
} else if (maxSize) {
caption += ` less than ${renderBytes(maxSize)}`;
}
return (
<div className={cn('flex flex-col items-center justify-center', className)}>
<div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
<UploadIcon size={16} />
</div>
<p className="my-2 w-full truncate text-wrap font-medium text-sm">
Upload {maxFiles === 1 ? 'a file' : 'files'}
</p>
<p className="w-full truncate text-wrap text-muted-foreground text-xs">
Drag and drop or click to upload
</p>
{caption && (
<p className="text-wrap text-muted-foreground text-xs">{caption}.</p>
)}
</div>
);
};

View File

@@ -0,0 +1,298 @@
"use client"
import { Github, LayoutDashboard, LogOut, User } from "lucide-react"
import Link from "next/link"
import type React from "react"
import { useState } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { pb } from "@/lib/pb"
interface UserData {
username: string
email: string
avatar?: string
}
interface UserButtonProps {
asChild?: boolean
isLoggedIn?: boolean
userData?: UserData
}
export function UserButton({ asChild, isLoggedIn = false, userData }: UserButtonProps) {
return (
<DropdownMenuTrigger asChild>
<Button
className="transition-colors duration-200 group hover:ring-2 rounded-lg cursor-pointer border border-border/50"
variant="ghost"
size="icon"
>
{isLoggedIn && userData ? (
<Avatar className="h-8 w-8">
<AvatarImage src={userData.avatar || "/placeholder.svg"} alt={userData.username} />
<AvatarFallback className="text-xs">{userData.username.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
) : (
<User className="h-[1.2rem] w-[1.2rem] transition-all group-hover:scale-110" />
)}
<span className="sr-only">{isLoggedIn ? "User menu" : "Sign in"}</span>
</Button>
</DropdownMenuTrigger>
)
}
interface UserMenuProps {
userData: UserData
onSignOut: () => void
}
export function UserMenu({ userData, onSignOut }: UserMenuProps) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3 px-1">
<Avatar className="h-10 w-10">
<AvatarImage src={userData.avatar || "/placeholder.svg"} alt={userData.username} />
<AvatarFallback className="text-sm font-semibold">{userData.username.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{userData.username}</p>
<p className="text-xs text-muted-foreground truncate">{userData.email}</p>
</div>
</div>
<DropdownMenuSeparator />
<Button asChild variant="ghost" className="w-full justify-start gap-2 hover:bg-muted">
<Link href="/dashboard">
<LayoutDashboard className="h-4 w-4" />
Dashboard
</Link>
</Button>
<Button
onClick={onSignOut}
asChild
variant="ghost"
className="w-full justify-start gap-2 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<div>
<LogOut className="h-4 w-4" />
Sign out
</div>
</Button>
</div>
)
}
interface LoginPopupProps {
trigger?: React.ReactNode
isLoggedIn?: boolean
userData?: UserData
onSignOut?: () => void
}
export function LoginPopup({ trigger, isLoggedIn = false, userData, onSignOut }: LoginPopupProps) {
const [open, setOpen] = useState(false)
const [isRegister, setIsRegister] = useState(false)
const [email, setEmail] = useState("")
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setIsLoading(true)
try {
if (isRegister) {
if (password !== confirmPassword) {
setError("Passwords do not match")
setIsLoading(false)
return
}
if (!username.trim()) {
setError("Username is required")
setIsLoading(false)
return
}
if (!email.trim()) {
setError("Email is required")
setIsLoading(false)
return
}
await pb.collection("users").create({
username: username.trim(),
email: email.trim(),
password,
passwordConfirm: confirmPassword,
})
await pb.collection("users").authWithPassword(email, password)
} else {
// For login, use email as the identifier
await pb.collection("users").authWithPassword(email, password)
}
setOpen(false)
setEmail("")
setUsername("")
setPassword("")
setConfirmPassword("")
} catch (err: any) {
console.error("Auth error:", err)
setError(err?.message || "Authentication failed. Please try again.")
} finally {
setIsLoading(false)
}
}
const toggleMode = () => {
setIsRegister(!isRegister)
setEmail("")
setUsername("")
setPassword("")
setConfirmPassword("")
setError("")
}
const handleSignOut = () => {
setOpen(false)
// Wait for dropdown close animation before updating parent state
setTimeout(() => {
onSignOut?.()
}, 150)
}
return (
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
{trigger || <UserButton isLoggedIn={isLoggedIn} userData={userData} />}
<DropdownMenuContent align="end" className="w-80 p-4">
{isLoggedIn && userData ? (
<UserMenu userData={userData} onSignOut={handleSignOut} />
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<h3 className="font-semibold text-lg">{isRegister ? "Create account" : "Sign in"}</h3>
<p className="text-sm text-muted-foreground">
{isRegister ? "Enter your details to create an account" : "Enter your credentials to continue"}
</p>
{error && <div className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md">{error}</div>}
</div>
<div
role="presentation"
aria-hidden="true"
className="flex h-10 w-full items-center justify-center gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm cursor-not-allowed opacity-50 pointer-events-none"
>
<Github className="h-4 w-4 opacity-40" />
<span className="opacity-40">Sign in with GitHub</span>
</div>
<div role="separator" aria-label="or" className="relative">
<Separator />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-popover px-2 text-xs text-muted-foreground uppercase">
or
</span>
</div>
<fieldset className="space-y-4 border-0 p-0">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
autoFocus
tabIndex={1}
name="email"
type="email"
autoComplete="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
{isRegister && <p className="text-xs text-muted-foreground">Used only to send you updates about your submissions</p>}
</div>
{isRegister && (
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
tabIndex={2}
name="username"
type="text"
autoComplete="username"
placeholder="Choose a username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">This will be displayed publicly with your submissions</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
tabIndex={isRegister ? 3 : 2}
name="password"
type="password"
autoComplete={isRegister ? "new-password" : "current-password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{isRegister && (
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
tabIndex={4}
name="confirmPassword"
type="password"
autoComplete="new-password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
)}
</fieldset>
<footer className="space-y-3">
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Please wait..." : isRegister ? "Register" : "Login"}
</Button>
<div className="text-center text-sm">
<button
type="button"
onClick={toggleMode}
className="text-muted-foreground hover:text-foreground transition-all duration-200 underline underline-offset-4 decoration-muted-foreground/50 cursor-pointer font-medium"
>
{isRegister ? "login" : "register"}
</button>
</div>
</footer>
</form>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { pb } from "@/lib/pb"
interface UserDisplayProps {
userId?: string
displayName: string
onClick?: (userId: string, displayName: string) => void
size?: "sm" | "md" | "lg"
showAvatar?: boolean
avatar?: string
}
const sizeClasses = {
sm: "h-6 w-6",
md: "h-8 w-8",
lg: "h-10 w-10",
}
const textSizeClasses = {
sm: "text-xs",
md: "text-sm",
lg: "text-sm",
}
export function UserDisplay({ userId, avatar, displayName, onClick, size = "sm", showAvatar = true }: UserDisplayProps) {
// Avatar URL will attempt to load from PocketBase
// If it doesn't exist, the AvatarFallback will display instead
const avatarUrl = userId ? `${pb.baseURL}/api/files/_pb_users_auth_/${userId}/${avatar}?thumb=100x100` : undefined
return (
<div className="flex items-center gap-2 ">
{showAvatar && (
<Avatar className={sizeClasses[size]}>
{avatarUrl && <AvatarImage src={avatarUrl} alt={displayName} />}
<AvatarFallback className={textSizeClasses[size]}>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
)}
{onClick && userId ? (
<Button
variant="link"
className={`h-auto p-0 ${textSizeClasses[size]} hover:underline`}
onClick={(e) => {
e.stopPropagation()
onClick(userId, displayName)
}}
>
{displayName}
</Button>
) : (
<span className={`${textSizeClasses[size]}`}>{displayName}</span>
)}
</div>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import { usePostHog } from "posthog-js/react"
import { useEffect, useRef } from "react"
import { pb } from "@/lib/pb"
import { identifyUserInPostHog, resetPostHogIdentity } from "@/lib/posthog-utils"
export function usePostHogAuth() {
const posthog = usePostHog()
const hasIdentified = useRef(false)
useEffect(() => {
const checkAuthAndIdentify = () => {
if (pb.authStore.isValid && pb.authStore.model) {
// User is logged in, identify them in PostHog
// Only call identify once per session to avoid unnecessary calls
if (!hasIdentified.current) {
identifyUserInPostHog(posthog)
hasIdentified.current = true
}
} else {
// User is not logged in, reset PostHog identity
// This unlinks future events from the user (important for shared computers)
resetPostHogIdentity(posthog)
hasIdentified.current = false
}
}
// Check auth state on mount
checkAuthAndIdentify()
// Listen for auth changes
const unsubscribe = pb.authStore.onChange(() => {
checkAuthAndIdentify()
})
// Cleanup listener on unmount
return () => {
unsubscribe()
}
}, [posthog])
}

View File

@@ -0,0 +1,170 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import { pb, type Submission } from "@/lib/pb"
import { getAllIcons } from "@/lib/api"
// Query key factory
export const submissionKeys = {
all: ["submissions"] as const,
lists: () => [...submissionKeys.all, "list"] as const,
list: (filters?: Record<string, any>) => [...submissionKeys.lists(), filters] as const,
}
// Fetch all submissions
export function useSubmissions() {
return useQuery({
queryKey: submissionKeys.lists(),
queryFn: async () => {
const records = await pb.collection("submissions").getFullList<Submission>({
sort: "-updated",
expand: "created_by,approved_by",
requestKey: null,
})
if (records.length > 0) {
}
return records
},
})
}
// Approve submission mutation
export function useApproveSubmission() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (submissionId: string) => {
return await pb.collection("submissions").update(
submissionId,
{
status: "approved",
approved_by: pb.authStore.record?.id || "",
},
{
requestKey: null,
},
)
},
onSuccess: (data) => {
// Invalidate and refetch submissions
queryClient.invalidateQueries({ queryKey: submissionKeys.lists() })
toast.success("Submission approved", {
description: "The submission has been approved successfully",
})
},
onError: (error: any) => {
console.error("Error approving submission:", error)
if (!error.message?.includes("autocancelled") && !error.name?.includes("AbortError")) {
toast.error("Failed to approve submission", {
description: error.message || "An error occurred",
})
}
},
})
}
// Reject submission mutation
export function useRejectSubmission() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (submissionId: string) => {
return await pb.collection("submissions").update(
submissionId,
{
status: "rejected",
approved_by: pb.authStore.record?.id || "",
},
{
requestKey: null,
},
)
},
onSuccess: () => {
// Invalidate and refetch submissions
queryClient.invalidateQueries({ queryKey: submissionKeys.lists() })
toast.success("Submission rejected", {
description: "The submission has been rejected",
})
},
onError: (error: any) => {
console.error("Error rejecting submission:", error)
if (!error.message?.includes("autocancelled") && !error.name?.includes("AbortError")) {
toast.error("Failed to reject submission", {
description: error.message || "An error occurred",
})
}
},
})
}
// Fetch existing icon names for the combobox + the metadata.json file
export function useExistingIconNames() {
return useQuery({
queryKey: ["existing-icon-names"],
queryFn: async () => {
const records = await pb.collection("community_gallery").getFullList({
fields: "name",
sort: "name",
requestKey: null,
})
const metadata = await getAllIcons()
const metadataNames = Object.keys(metadata)
const uniqueRecordsNames = Array.from(new Set(records.map((r) => r.name)))
const uniqueMetadataNames = Array.from(new Set(metadataNames.map((n) => n)))
const uniqueNames = Array.from(new Set(uniqueRecordsNames.concat(uniqueMetadataNames)))
return uniqueNames.map((name) => ({
label: name,
value: name,
}))
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: false,
})
}
// Check authentication status
export function useAuth() {
return useQuery({
queryKey: ["auth"],
queryFn: async () => {
const isValid = pb.authStore.isValid
const userId = pb.authStore.record?.id
if (!isValid || !userId) {
return {
isAuthenticated: false,
isAdmin: false,
userId: "",
}
}
try {
// Fetch the full user record to get the admin status
const user = await pb.collection("users").getOne(userId, {
requestKey: null,
})
return {
isAuthenticated: true,
isAdmin: user?.admin === true,
userId: userId,
}
} catch (error) {
console.error("Error fetching user:", error)
return {
isAuthenticated: isValid,
isAdmin: false,
userId: userId || "",
}
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
retry: false,
})
}

78
web/src/lib/community.ts Normal file
View File

@@ -0,0 +1,78 @@
import { unstable_cache } from "next/cache"
import PocketBase from "pocketbase"
import type { CommunityGallery } from "@/lib/pb"
import type { IconWithName } from "@/types/icons"
/**
* Server-side utility functions for community gallery (public submissions view)
*/
/**
* Create a new PocketBase instance for server-side operations
* Note: Do not use the client-side pb instance (with auth store) on the server
*/
function createServerPB() {
return new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090")
}
/**
* Transform a CommunityGallery item to IconWithName format for use with IconSearch
*/
function transformGalleryToIcon(item: CommunityGallery): any {
const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"
const fileUrl = item.assets?.[0] ? `${pbUrl}/api/files/community_gallery/${item.id}/${item.assets[0]}` : ""
const transformed = {
name: item.name,
status: item.status,
data: {
base: fileUrl || "svg",
aliases: item.extras?.aliases || [],
categories: item.extras?.categories || [],
update: {
timestamp: item.created,
author: {
id: 0,
name: item.created_by || "Community",
},
},
colors: item.extras?.colors,
wordmark: item.extras?.wordmark,
},
}
return transformed
}
/**
* Fetch community gallery items (not added to collection)
* Uses the community_gallery view collection for public-facing data
* This is the raw fetch function without caching
* Filters out items without assets
*/
export async function fetchCommunitySubmissions(): Promise<IconWithName[]> {
try {
const pb = createServerPB()
const records = await pb.collection("community_gallery").getFullList<CommunityGallery>({
filter: 'status != "added_to_collection"',
sort: "-created",
})
return records.filter((item) => item.assets && item.assets.length > 0).map(transformGalleryToIcon)
} catch (error) {
console.error("Error fetching community submissions:", error)
return []
}
}
/**
* Cached version of fetchCommunitySubmissions
* Uses unstable_cache with tags for on-demand revalidation
* Revalidates every 600 seconds (10 minutes)
*/
export const getCommunitySubmissions = unstable_cache(fetchCommunitySubmissions, ["community-gallery"], {
revalidate: 600,
tags: ["community-gallery"],
})

71
web/src/lib/pb.ts Normal file
View File

@@ -0,0 +1,71 @@
import PocketBase, { type RecordService } from "pocketbase"
export interface User {
id: string
username: string
email: string
admin?: boolean
avatar?: string
created: string
updated: string
}
export interface Submission {
id: string
name: string
assets: string[]
created_by: string
status: "approved" | "rejected" | "pending" | "added_to_collection"
approved_by: string
expand: {
created_by: User
approved_by: User
}
extras: {
aliases: string[]
categories: string[]
base?: string
colors?: {
dark?: string
light?: string
}
wordmark?: {
dark?: string
light?: string
}
}
created: string
updated: string
}
export interface CommunityGallery {
id: string
name: string
created_by: string
status: "approved" | "rejected" | "pending" | "added_to_collection"
assets: string[]
created: string
updated: string
extras: {
aliases: string[]
categories: string[]
base?: string
colors?: {
dark?: string
light?: string
}
wordmark?: {
dark?: string
light?: string
}
}
}
interface TypedPocketBase extends PocketBase {
collection(idOrName: string): RecordService // default fallback for any other collection
collection(idOrName: "users"): RecordService<User>
collection(idOrName: "submissions"): RecordService<Submission>
collection(idOrName: "community_gallery"): RecordService<CommunityGallery>
}
export const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090") as TypedPocketBase

View File

@@ -0,0 +1,45 @@
import { pb } from "./pb"
/**
* Identifies a user in PostHog with their PocketBase user data
* Follows PostHog best practices for user identification
*
* @param posthog - PostHog instance
* @param user - PocketBase user model (optional, will use current auth if not provided)
*/
export function identifyUserInPostHog(posthog: any, user?: any) {
if (!posthog) return
const userData = user || pb.authStore.model
if (!userData) return
// Use PocketBase user ID as distinct_id (unique string)
// Pass all available person properties for complete profile
posthog.identify(userData.id, {
email: userData.email,
username: userData.username,
name: userData.username, // Use username as name if no separate name field
created: userData.created,
updated: userData.updated,
admin: userData.admin || false,
avatar: userData.avatar || null,
// Add any other relevant user properties
user_id: userData.id,
email_verified: userData.emailVisibility || false,
})
}
/**
* Resets PostHog identity (should be called on logout)
* This unlinks future events from the user
*
* @param posthog - PostHog instance
*/
export function resetPostHogIdentity(posthog: any) {
if (!posthog) return
// Reset PostHog identity to unlink future events from this user
// This is important for shared computers and follows PostHog best practices
posthog.reset()
}

21
web/src/lib/revalidate.ts Normal file
View File

@@ -0,0 +1,21 @@
"use server"
import { revalidatePath, revalidateTag } from "next/cache"
/**
* Revalidate the community page cache
* Can be called from server actions after submission approval/rejection
*/
export async function revalidateCommunityPage() {
revalidatePath("/community")
revalidateTag("community-gallery")
}
/**
* Revalidate all submission-related caches
*/
export async function revalidateSubmissions() {
revalidateTag("community-gallery")
revalidatePath("/community")
revalidatePath("/dashboard")
}

View File

@@ -1,6 +1,6 @@
import type { IconWithName } from "@/types/icons"
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import type { IconWithName } from "@/types/icons"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))