mirror of
https://github.com/walkxcode/dashboard-icons.git
synced 2025-10-26 21:19:04 +08:00
Compare commits
97 Commits
main
...
feat/new-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c87e66918 | ||
|
|
cd1a3fda59 | ||
|
|
888d1f26ac | ||
|
|
0fd6db891f | ||
|
|
7dc93ac86f | ||
|
|
a9a97f54b5 | ||
|
|
676ee079d6 | ||
|
|
e4fa1a4d31 | ||
|
|
baa85d4b79 | ||
|
|
555898fa69 | ||
|
|
7fe7d43c1a | ||
|
|
758c4a5bbc | ||
|
|
c5949aab03 | ||
|
|
ef8bc885d2 | ||
|
|
d28b495421 | ||
|
|
fe9f5edc9a | ||
|
|
08ff932257 | ||
|
|
a2fbc03bd6 | ||
|
|
1d0f264dda | ||
|
|
6cb2f39a1d | ||
|
|
391a69f82e | ||
|
|
e31b97f60e | ||
|
|
2ef6e4162c | ||
|
|
8a4c92930d | ||
|
|
23e0ea54ff | ||
|
|
e9e9aefb79 | ||
|
|
6fc0a06fc4 | ||
|
|
f0215627d7 | ||
|
|
f4819acc7c | ||
|
|
c471b87436 | ||
|
|
9f4a1d9387 | ||
|
|
d542377d97 | ||
|
|
680246d50e | ||
|
|
9918c5507e | ||
|
|
d2a94382da | ||
|
|
13a1192dc2 | ||
|
|
4e58f705d3 | ||
|
|
e1ae75d27f | ||
|
|
0a9d700144 | ||
|
|
9cb8e220cb | ||
|
|
171e897280 | ||
|
|
ec9453aa4f | ||
|
|
dd4bd2e565 | ||
|
|
c210b4a8c5 | ||
|
|
f45fa072af | ||
|
|
310190f6c1 | ||
|
|
22ac70dc9f | ||
|
|
3aa0c84f75 | ||
|
|
95e9497c2e | ||
|
|
c0944d5423 | ||
|
|
f1b198a6d4 | ||
|
|
a369676609 | ||
|
|
49aab75953 | ||
|
|
0a4a4a78f4 | ||
|
|
07f196f12f | ||
|
|
63507a767a | ||
|
|
d221fb5c79 | ||
|
|
c0851a73c7 | ||
|
|
77e55e750f | ||
|
|
b277ceb9a0 | ||
|
|
7653ee6e17 | ||
|
|
fd8b50776a | ||
|
|
5f5e3ef825 | ||
|
|
bf93408568 | ||
|
|
db893d4f97 | ||
|
|
fc39fd12c9 | ||
|
|
c11bcfa179 | ||
|
|
ddf1f13d7a | ||
|
|
da40db6183 | ||
|
|
3d0eab4f01 | ||
|
|
3f4052080c | ||
|
|
c02e773be4 | ||
|
|
1b2837ac5a | ||
|
|
fb99a7ff9a | ||
|
|
be90e727c1 | ||
|
|
f600ba5abb | ||
|
|
36d4128e96 | ||
|
|
3b6a8ad39f | ||
|
|
9e2aeea596 | ||
|
|
5194a53fda | ||
|
|
b8920b912a | ||
|
|
a93034d5b5 | ||
|
|
a69830c98d | ||
|
|
a1bbfd6d23 | ||
|
|
c56586f5ba | ||
|
|
e10008ece5 | ||
|
|
69d0b1f2e5 | ||
|
|
779a7a48ab | ||
|
|
579ad9d6eb | ||
|
|
763a204c8e | ||
|
|
036effc872 | ||
|
|
eedded4e47 | ||
|
|
9cbcb67feb | ||
|
|
03649e45e1 | ||
|
|
73e787e5b8 | ||
|
|
b5c72677fc | ||
|
|
68970f5908 |
10
dashboard-icons.code-workspace
Normal file
10
dashboard-icons.code-workspace
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"biome.configurationPath": "./web/biome.jsonc"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
web/.gitignore
vendored
7
web/.gitignore
vendored
@@ -43,3 +43,10 @@ next-env.d.ts
|
|||||||
# Cursor
|
# Cursor
|
||||||
.cursor
|
.cursor
|
||||||
.env.local
|
.env.local
|
||||||
|
# Turborepo
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
# Pocketbase
|
||||||
|
backend/pocketbase
|
||||||
|
backend/pb_data
|
||||||
|
backend/pb_migrations
|
||||||
916
web/SEO.md
916
web/SEO.md
@@ -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
0
web/backend/.gitkeep
Normal file
BIN
web/backend/pocketbase
Executable file
BIN
web/backend/pocketbase
Executable file
Binary file not shown.
@@ -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": {
|
"vcs": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientKind": "git",
|
"clientKind": "git",
|
||||||
@@ -7,27 +7,36 @@
|
|||||||
},
|
},
|
||||||
"files": {
|
"files": {
|
||||||
"ignoreUnknown": false,
|
"ignoreUnknown": false,
|
||||||
"ignore": ["src/components/ui/**"]
|
"includes": ["src/**", "!src/components/ui"]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
|
"lineWidth": 140,
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "tab"
|
"indentStyle": "tab"
|
||||||
},
|
},
|
||||||
"organizeImports": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true,
|
"recommended": true,
|
||||||
|
"correctness": {
|
||||||
|
"useUniqueElementIds": "off"
|
||||||
|
},
|
||||||
"suspicious": {
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off",
|
||||||
|
"noUnknownAtRules": "off",
|
||||||
"noArrayIndexKey": "off"
|
"noArrayIndexKey": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"assist": {
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"lineWidth": 140,
|
|
||||||
"semicolons": "asNeeded",
|
"semicolons": "asNeeded",
|
||||||
"quoteStyle": "double"
|
"quoteStyle": "double"
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"cssVariables": true,
|
"cssVariables": true,
|
||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -17,5 +18,7 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide"
|
"registries": {
|
||||||
|
"@magicui": "https://magicui.design/r/{name}.json"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ const nextConfig: NextConfig = {
|
|||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
output: "export",
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"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",
|
"build": "next build",
|
||||||
"start": "pnpx serve@latest out",
|
"start": "next start",
|
||||||
"format": "biome check --write",
|
"format": "biome check --write",
|
||||||
"lint": "biome lint --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": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
@@ -38,24 +43,33 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@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",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"canvas-confetti": "^1.9.3",
|
"canvas-confetti": "^1.9.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dayjs": "^1.11.18",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.543.0",
|
"lucide-react": "^0.544.0",
|
||||||
"motion": "^12.23.12",
|
"motion": "^12.23.12",
|
||||||
"next": "15.5.2",
|
"next": "15.5.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"posthog-js": "^1.262.0",
|
"pocketbase": "^0.26.2",
|
||||||
"posthog-node": "^5.8.2",
|
"posthog-js": "^1.268.7",
|
||||||
|
"posthog-node": "^5.9.1",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-resizable-panels": "^2.1.9",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
@@ -67,7 +81,7 @@
|
|||||||
"zod": "^4.1.5"
|
"zod": "^4.1.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.9.4",
|
"@biomejs/biome": "2.2.4",
|
||||||
"@tailwindcss/postcss": "^4.1.13",
|
"@tailwindcss/postcss": "^4.1.13",
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^22.18.1",
|
"@types/node": "^22.18.1",
|
||||||
@@ -77,10 +91,11 @@
|
|||||||
"typescript": "^5.9.2",
|
"typescript": "^5.9.2",
|
||||||
"wrangler": "^4.35.0"
|
"wrangler": "^4.35.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.15.1",
|
"packageManager": "pnpm@10.17.1",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@biomejs/biome",
|
"@biomejs/biome",
|
||||||
|
"@tailwindcss/oxide",
|
||||||
"core-js",
|
"core-js",
|
||||||
"esbuild",
|
"esbuild",
|
||||||
"sharp",
|
"sharp",
|
||||||
|
|||||||
620
web/pnpm-lock.yaml
generated
620
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
216
web/seed-db.ts
Normal file
216
web/seed-db.ts
Normal 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)
|
||||||
|
})
|
||||||
|
|
||||||
36
web/src/app/actions/submissions.ts
Normal file
36
web/src/app/actions/submissions.ts
Normal 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
23
web/src/app/community/layout.tsx
Normal file
23
web/src/app/community/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
web/src/app/community/page.tsx
Normal file
58
web/src/app/community/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
web/src/app/dashboard/layout.tsx
Normal file
23
web/src/app/dashboard/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
web/src/app/dashboard/page.tsx
Normal file
130
web/src/app/dashboard/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { AlertTriangle, ArrowLeft, RefreshCcw } from "lucide-react"
|
import { AlertTriangle, ArrowLeft, RefreshCcw } from "lucide-react"
|
||||||
import Link from "next/link"
|
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
export default function ErrorPage({
|
export default function ErrorPage({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
error,
|
|
||||||
reset,
|
|
||||||
}: {
|
|
||||||
error: Error & { digest?: string }
|
|
||||||
reset: () => void
|
|
||||||
}) {
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -42,6 +42,10 @@
|
|||||||
--font-mono: var(--font-mono);
|
--font-mono: var(--font-mono);
|
||||||
--font-serif: var(--font-serif);
|
--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-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -118,112 +122,169 @@
|
|||||||
transform: rotate(-5deg) scale(0.9);
|
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 {
|
:root {
|
||||||
--radius: 0.4rem;
|
--radius: 0.3rem;
|
||||||
|
|
||||||
--background: oklch(0.99 0 0);
|
--background: oklch(1.0000 0 0);
|
||||||
--foreground: oklch(0.32 0 0);
|
--foreground: oklch(0.1884 0.0128 248.5103);
|
||||||
--card: oklch(1.0 0 0);
|
--card: oklch(1.0000 0 0);
|
||||||
--card-foreground: oklch(0.32 0 0);
|
--card-foreground: oklch(0.1884 0.0128 248.5103);
|
||||||
--popover: oklch(1.0 0 0);
|
--popover: oklch(1.0000 0 0);
|
||||||
--popover-foreground: oklch(0.32 0 0);
|
--popover-foreground: oklch(0.1884 0.0128 248.5103);
|
||||||
--primary: oklch(0.67 0.2 23.8);
|
--primary: oklch(0.6723 0.1606 244.9955);
|
||||||
--primary-foreground: oklch(1.0 0 0);
|
--primary-foreground: oklch(1.0000 0 0);
|
||||||
--secondary: oklch(0.97 0.0 264.54);
|
--muted: oklch(0.98 0 247.84);
|
||||||
--secondary-foreground: oklch(0.45 0.03 256.8);
|
|
||||||
--muted: oklch(0.98 0.0 247.84);
|
|
||||||
--muted-foreground: oklch(0.55 0.02 264.36);
|
--muted-foreground: oklch(0.55 0.02 264.36);
|
||||||
--accent: oklch(0.967 0.001 286.375);
|
--accent: oklch(0.9392 0.0166 250.8453);
|
||||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
--accent-foreground: oklch(0.6723 0.1606 244.9955);
|
||||||
--destructive: oklch(0.64 0.21 25.33);
|
--destructive: oklch(0.6188 0.2376 25.7658);
|
||||||
--destructive-foreground: oklch(1.0 0 0);
|
--destructive-foreground: oklch(1.0000 0 0);
|
||||||
--border: oklch(0.9 0.01 247.88);
|
--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-1: oklch(0.6723 0.1606 244.9955);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6907 0.1554 160.3454);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.8214 0.1600 82.5337);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.7064 0.1822 151.7125);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.5919 0.2186 10.5826);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.9784 0.0011 197.1387);
|
||||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
--sidebar-foreground: oklch(0.1884 0.0128 248.5103);
|
||||||
--sidebar-primary: oklch(0.637 0.237 25.331);
|
--sidebar-primary: oklch(0.6723 0.1606 244.9955);
|
||||||
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
|
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
--sidebar-accent: oklch(0.9392 0.0166 250.8453);
|
||||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
--sidebar-accent-foreground: oklch(0.6723 0.1606 244.9955);
|
||||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
--sidebar-border: oklch(0.9271 0.0101 238.5177);
|
||||||
--sidebar-ring: oklch(0.637 0.237 25.331);
|
--sidebar-ring: oklch(0.6818 0.1584 243.3540);
|
||||||
|
|
||||||
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
|
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||||
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
|
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||||
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
|
--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);
|
||||||
hsl(0 0% 0% / 0.1);
|
--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: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
|
--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);
|
||||||
hsl(0 0% 0% / 0.1);
|
--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-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 2px 4px -1px
|
--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);
|
||||||
hsl(0 0% 0% / 0.1);
|
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||||
--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);
|
|
||||||
|
|
||||||
--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 {
|
.dark {
|
||||||
--background: oklch(0.141 0.005 285.823);
|
--background: oklch(0 0 0);
|
||||||
--foreground: oklch(0.92 0 0);
|
--foreground: oklch(0.9328 0.0025 228.7857);
|
||||||
--card: oklch(0.31 0.03 268.64);
|
--card: oklch(0.2097 0.0080 274.5332);
|
||||||
--card-foreground: oklch(0.92 0 0);
|
--card-foreground: oklch(0.8853 0 0);
|
||||||
--popover: oklch(0.29 0.02 268.4);
|
--popover: oklch(0 0 0);
|
||||||
--popover-foreground: oklch(0.92 0 0);
|
--popover-foreground: oklch(0.9328 0.0025 228.7857);
|
||||||
--primary: oklch(0.67 0.2 23.8);
|
--primary: oklch(0.6700 0.2000 23.8000);
|
||||||
--primary-foreground: oklch(1.0 0 0);
|
--primary-foreground: oklch(1.0000 0 0);
|
||||||
--secondary: oklch(0.31 0.03 266.71);
|
--secondary: oklch(0.9622 0.0035 219.5331);
|
||||||
--secondary-foreground: oklch(0.92 0 0);
|
--secondary-foreground: oklch(0.1884 0.0128 248.5103);
|
||||||
--muted: oklch(0.31 0.03 266.71);
|
--muted: oklch(0.31 0.03 266.71);
|
||||||
--muted-foreground: oklch(0.78 0 0);
|
--muted-foreground: oklch(0.78 0 0);
|
||||||
--accent: oklch(0.34 0.06 267.59);
|
--accent: oklch(0.1928 0.0331 242.5459);
|
||||||
--accent-foreground: oklch(0.88 0.06 254.13);
|
--accent-foreground: oklch(0.6448 0.2290 20.4673);
|
||||||
--destructive: oklch(0.64 0.21 25.33);
|
--destructive: oklch(0.6188 0.2376 25.7658);
|
||||||
--destructive-foreground: oklch(1.0 0 0);
|
--destructive-foreground: oklch(1.0000 0 0);
|
||||||
--border: oklch(0.38 0.03 269.73);
|
--border: oklch(0.38 0.03 269.73);
|
||||||
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
--input: oklch(0.3020 0.0288 244.8244);
|
||||||
--ring: oklch(0.637 0.237 25.331);
|
--ring: oklch(0.6700 0.2000 23.8000);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.6723 0.1606 244.9955);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.6907 0.1554 160.3454);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.8214 0.1600 82.5337);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.7064 0.1822 151.7125);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.5919 0.2186 10.5826);
|
||||||
--sidebar: oklch(0.21 0.006 285.885);
|
--sidebar: oklch(0.2097 0.0080 274.5332);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.8853 0 0);
|
||||||
--sidebar-primary: oklch(0.637 0.237 25.331);
|
--sidebar-primary: oklch(0.6700 0.2000 23.8000);
|
||||||
--sidebar-primary-foreground: oklch(0.971 0.013 17.38);
|
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
||||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
--sidebar-accent: oklch(0.1928 0.0331 242.5459);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.5869 0.2251 31.5657);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(0.3795 0.0220 240.5943);
|
||||||
--sidebar-ring: oklch(0.637 0.237 25.331);
|
--sidebar-ring: oklch(0.4952 0.1902 31.5028);
|
||||||
|
|
||||||
--shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
|
--shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||||
--shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.05);
|
--shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||||
--shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
|
--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);
|
||||||
hsl(0 0% 0% / 0.1);
|
--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: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 1px 2px -1px
|
--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);
|
||||||
hsl(0 0% 0% / 0.1);
|
--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-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 2px 4px -1px
|
--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);
|
||||||
hsl(0 0% 0% / 0.1);
|
--shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
|
||||||
--shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 4px 6px -1px
|
|
||||||
hsl(0 0% 0% / 0.1);
|
--magic-gradient-color: oklch(0.67 0.2 23.8 / 15%);
|
||||||
--shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.1), 0px 8px 10px -1px
|
|
||||||
hsl(0 0% 0% / 0.1);
|
--radius: 0.3rem;
|
||||||
--shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.25);
|
|
||||||
|
--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 {
|
@layer base {
|
||||||
@@ -232,6 +293,7 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
letter-spacing: var(--tracking-normal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,3 +314,31 @@
|
|||||||
@apply backdrop-blur-sm;
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { getAllIcons } from "@/lib/api"
|
|
||||||
import { ImageResponse } from "next/og"
|
import { ImageResponse } from "next/og"
|
||||||
|
import { getAllIcons } from "@/lib/api"
|
||||||
|
|
||||||
export const dynamic = "force-static"
|
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`)
|
const iconPath = join(process.cwd(), `../png/${icon}.png`)
|
||||||
console.log(`Generating opengraph image for ${icon} (${index + 1} / ${totalIcons}) from path ${iconPath}`)
|
console.log(`Generating opengraph image for ${icon} (${index + 1} / ${totalIcons}) from path ${iconPath}`)
|
||||||
iconData = await readFile(iconPath)
|
iconData = await readFile(iconPath)
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error(`Icon ${icon} was not found locally`)
|
console.error(`Icon ${icon} was not found locally`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import type { Metadata, ResolvingMetadata } from "next"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
import { IconDetails } from "@/components/icon-details"
|
import { IconDetails } from "@/components/icon-details"
|
||||||
import { BASE_URL, WEB_URL } from "@/constants"
|
import { BASE_URL, WEB_URL } from "@/constants"
|
||||||
import { getAllIcons, getAuthorData } from "@/lib/api"
|
import { getAllIcons, getAuthorData } from "@/lib/api"
|
||||||
import type { Metadata, ResolvingMetadata } from "next"
|
|
||||||
import { notFound } from "next/navigation"
|
|
||||||
export const dynamicParams = false
|
export const dynamicParams = false
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
@@ -19,7 +19,7 @@ type Props = {
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
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 { icon } = await params
|
||||||
const iconsData = await getAllIcons()
|
const iconsData = await getAllIcons()
|
||||||
if (!iconsData[icon]) {
|
if (!iconsData[icon]) {
|
||||||
@@ -76,13 +76,14 @@ export async function generateMetadata({ params, searchParams }: Props, parent:
|
|||||||
type: "website",
|
type: "website",
|
||||||
url: pageUrl,
|
url: pageUrl,
|
||||||
siteName: "Dashboard Icons",
|
siteName: "Dashboard Icons",
|
||||||
images: [{
|
images: [
|
||||||
|
{
|
||||||
url: `${BASE_URL}/webp/${icon}.webp`,
|
url: `${BASE_URL}/webp/${icon}.webp`,
|
||||||
width: 512,
|
width: 512,
|
||||||
height: 512,
|
height: 512,
|
||||||
alt: `${formattedIconName} icon`,
|
alt: `${formattedIconName} icon`,
|
||||||
}]
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface BackgroundWrapperProps {
|
interface BackgroundWrapperProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getAllIcons } from "@/lib/api"
|
|
||||||
import { ImageResponse } from "next/og"
|
import { ImageResponse } from "next/og"
|
||||||
|
import { getAllIcons } from "@/lib/api"
|
||||||
|
|
||||||
export const dynamic = "force-static"
|
export const dynamic = "force-static"
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import type { Metadata } from "next"
|
||||||
import { IconSearch } from "@/components/icon-search"
|
import { IconSearch } from "@/components/icon-search"
|
||||||
import { BASE_URL } from "@/constants"
|
import { BASE_URL } from "@/constants"
|
||||||
import { getIconsArray } from "@/lib/api"
|
import { getIconsArray } from "@/lib/api"
|
||||||
import type { Metadata } from "next"
|
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const icons = await getIconsArray()
|
const icons = await getIconsArray()
|
||||||
|
|||||||
@@ -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 type { Metadata, Viewport } from "next"
|
||||||
import { Inter } from "next/font/google"
|
import { Inter } from "next/font/google"
|
||||||
import { Toaster } from "sonner"
|
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 "./globals.css"
|
||||||
|
import { Providers } from "@/components/providers"
|
||||||
import { ThemeProvider } from "./theme-provider"
|
import { ThemeProvider } from "./theme-provider"
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@@ -85,6 +86,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}>
|
<body className={`${inter.variable} antialiased bg-background flex flex-col min-h-screen`}>
|
||||||
|
<Providers>
|
||||||
<PostHogProvider>
|
<PostHogProvider>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
<HeaderWrapper />
|
<HeaderWrapper />
|
||||||
@@ -94,6 +96,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
|||||||
<LicenseNotice />
|
<LicenseNotice />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
|
import { AlertTriangle, ArrowLeft } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
import { IconSubmissionContent } from "@/components/icon-submission-form"
|
import { IconSubmissionContent } from "@/components/icon-submission-form"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { AlertTriangle, ArrowLeft, PlusCircle } from "lucide-react"
|
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
export default function NotFound({
|
export default function NotFound({ error }: { error: Error & { digest?: string } }) {
|
||||||
error,
|
|
||||||
}: {
|
|
||||||
error: Error & { digest?: string }
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="py-16 flex items-center justify-center">
|
<div className="py-16 flex items-center justify-center">
|
||||||
<div className="text-center space-y-8 max-w-2xl mx-auto">
|
<div className="text-center space-y-8 max-w-2xl mx-auto">
|
||||||
@@ -27,17 +23,6 @@ export default function NotFound({
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import type { MetadataRoute } from "next"
|
||||||
import { BASE_URL, WEB_URL } from "@/constants"
|
import { BASE_URL, WEB_URL } from "@/constants"
|
||||||
import { getAllIcons } from "@/lib/api"
|
import { getAllIcons } from "@/lib/api"
|
||||||
import type { MetadataRoute } from "next"
|
|
||||||
|
|
||||||
export const dynamic = "force-static"
|
export const dynamic = "force-static"
|
||||||
|
|
||||||
|
|||||||
23
web/src/app/submit/layout.tsx
Normal file
23
web/src/app/submit/layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
web/src/app/submit/page.tsx
Normal file
90
web/src/app/submit/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ import { usePathname, useSearchParams } from "next/navigation"
|
|||||||
import posthog from "posthog-js"
|
import posthog from "posthog-js"
|
||||||
import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"
|
import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"
|
||||||
import { Suspense, useEffect } from "react"
|
import { Suspense, useEffect } from "react"
|
||||||
|
import { usePostHogAuth } from "@/hooks/use-posthog-auth"
|
||||||
|
|
||||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||||
useEffect(() => {
|
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",
|
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://eu.i.posthog.com",
|
||||||
capture_pageview: false, // We capture pageviews manually
|
capture_pageview: false, // We capture pageviews manually
|
||||||
capture_pageleave: true, // Enable pageleave capture
|
capture_pageleave: true, // Enable pageleave capture
|
||||||
|
person_profiles: 'identified_only',
|
||||||
loaded(posthogInstance) {
|
loaded(posthogInstance) {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
window.posthog = posthogInstance
|
window.posthog = posthogInstance
|
||||||
@@ -23,12 +25,18 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PHProvider client={posthog}>
|
<PHProvider client={posthog}>
|
||||||
|
<PostHogAuthHandler />
|
||||||
<SuspendedPostHogPageView />
|
<SuspendedPostHogPageView />
|
||||||
{children}
|
{children}
|
||||||
</PHProvider>
|
</PHProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PostHogAuthHandler() {
|
||||||
|
usePostHogAuth()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function PostHogPageView() {
|
function PostHogPageView() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|||||||
592
web/src/components/advanced-icon-submission-form-tanstack.tsx
Normal file
592
web/src/components/advanced-icon-submission-form-tanstack.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
export function Carbon() {
|
export function Carbon() {
|
||||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
|
||||||
const ref = useRef<HTMLDivElement>(null!)
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -16,6 +14,8 @@ export function Carbon() {
|
|||||||
ref.current.appendChild(s)
|
ref.current.appendChild(s)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement>(null!)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client"
|
"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 { Info, Search as SearchIcon, Tag } from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
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 {
|
interface CommandMenuProps {
|
||||||
icons: IconWithName[]
|
icons: IconWithName[]
|
||||||
@@ -20,7 +20,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [internalOpen, setInternalOpen] = useState(false)
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
const [query, setQuery] = useState("")
|
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
|
// Use either external or internal state for controlling open state
|
||||||
const isOpen = externalOpen !== undefined ? externalOpen : internalOpen
|
const isOpen = externalOpen !== undefined ? externalOpen : internalOpen
|
||||||
@@ -69,7 +69,7 @@ export function CommandMenu({ icons, open: externalOpen, onOpenChange: externalO
|
|||||||
return (
|
return (
|
||||||
<CommandDialog open={isOpen} onOpenChange={setIsOpen} contentClassName="bg-background/90 backdrop-blur-sm border border-border/60">
|
<CommandDialog open={isOpen} onOpenChange={setIsOpen} contentClassName="bg-background/90 backdrop-blur-sm border border-border/60">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={`Search our collection of ${totalIcons} icons by name or category...`}
|
placeholder={`Search our collection of ${totalIcons} icons by name...`}
|
||||||
value={query}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onValueChange={setQuery}
|
||||||
/>
|
/>
|
||||||
|
|||||||
426
web/src/components/community-icon-search.tsx
Normal file
426
web/src/components/community-icon-search.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
719
web/src/components/editable-icon-details.tsx
Normal file
719
web/src/components/editable-icon-details.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { REPO_PATH } from "@/constants"
|
|
||||||
import { ExternalLink } from "lucide-react"
|
import { ExternalLink } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { REPO_PATH } from "@/constants"
|
||||||
import { HeartEasterEgg } from "./heart"
|
import { HeartEasterEgg } from "./heart"
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
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 pathname = usePathname()
|
||||||
const isIconsActive = pathname === "/icons" || pathname.startsWith("/icons/")
|
const isIconsActive = pathname === "/icons" || pathname.startsWith("/icons/")
|
||||||
|
const isCommunityActive = pathname === "/community" || pathname.startsWith("/community/")
|
||||||
|
const isDashboardActive = pathname === "/dashboard" || pathname.startsWith("/dashboard/")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex flex-row md:items-center items-start gap-4 md:gap-6">
|
<nav className="flex flex-row md:items-center items-start gap-4 md:gap-6">
|
||||||
@@ -29,6 +35,27 @@ export function HeaderNav() {
|
|||||||
>
|
>
|
||||||
Icons
|
Icons
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,45 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { IconSubmissionForm } from "@/components/icon-submission-form"
|
import { Github, LayoutDashboard, LogOut, PlusCircle, Search, Star } from "lucide-react"
|
||||||
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 Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useEffect, useState } from "react"
|
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 { CommandMenu } from "./command-menu"
|
||||||
import { HeaderNav } from "./header-nav"
|
import { HeaderNav } from "./header-nav"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"
|
||||||
import { Button } from "./ui/button"
|
import { Button } from "./ui/button"
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"
|
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() {
|
export function Header() {
|
||||||
const [iconsData, setIconsData] = useState<IconWithName[]>([])
|
const [iconsData, setIconsData] = useState<IconWithName[]>([])
|
||||||
const [isLoaded, setIsLoaded] = useState(false)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [commandMenuOpen, setCommandMenuOpen] = 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(() => {
|
useEffect(() => {
|
||||||
async function loadIcons() {
|
async function loadIcons() {
|
||||||
@@ -33,11 +56,75 @@ export function Header() {
|
|||||||
loadIcons()
|
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 = () => {
|
const openCommandMenu = () => {
|
||||||
setCommandMenuOpen(true)
|
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 (
|
return (
|
||||||
<header className="border-b sticky top-0 z-50 backdrop-blur-2xl bg-background/50 border-border/50">
|
<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">
|
<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>
|
<span className="transition-colors duration-300 group-hover:">Dashboard Icons</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex-nowrap">
|
<div className="flex-nowrap">
|
||||||
<HeaderNav />
|
<HeaderNav isLoggedIn={isLoggedIn} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 md:gap-4">
|
<div className="flex items-center gap-2 md:gap-4">
|
||||||
@@ -66,7 +153,7 @@ export function Header() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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}
|
onClick={openCommandMenu}
|
||||||
>
|
>
|
||||||
<Search className="h-5 w-5 transition-all duration-300" />
|
<Search className="h-5 w-5 transition-all duration-300" />
|
||||||
@@ -74,32 +161,51 @@ export function Header() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Submit Button -> triggers IconSubmissionForm dialog */}
|
{/* Mobile Submit Button */}
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<IconSubmissionForm
|
{isLoggedIn ? (
|
||||||
trigger={
|
<Button variant="ghost" size="icon" className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2" asChild>
|
||||||
<Button variant="ghost" size="icon" className="rounded-lg cursor-pointer transition-all duration-300 hover:ring-2 ">
|
<Link href="/submit">
|
||||||
<PlusCircle className="h-5 w-5 transition-all duration-300" />
|
<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>
|
</Button>
|
||||||
}
|
) : (
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden md:flex items-center gap-2 md:gap-4">
|
|
||||||
{/* Desktop Submit Button */}
|
|
||||||
<IconSubmissionForm />
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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"
|
||||||
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" />
|
<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>
|
<span className="sr-only">View on GitHub</span>
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -111,11 +217,64 @@ export function Header() {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<ThemeSwitcher />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Single instance of CommandMenu */}
|
{/* Single instance of CommandMenu */}
|
||||||
{isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />}
|
{isLoaded && <CommandMenu icons={iconsData} open={commandMenuOpen} onOpenChange={setCommandMenuOpen} />}
|
||||||
|
|
||||||
|
{/* Login Modal */}
|
||||||
|
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Heart } from "lucide-react"
|
|
||||||
|
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
|
import { Heart } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
export function HeartEasterEgg() {
|
export function HeartEasterEgg() {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
"use client"
|
"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 { Separator } from "@radix-ui/react-dropdown-menu"
|
||||||
import { motion, useAnimation, useInView } from "framer-motion"
|
import { motion, useAnimation, useInView } from "framer-motion"
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +21,10 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useEffect, useRef, useState } from "react"
|
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 { AuroraText } from "./magicui/aurora-text"
|
||||||
import { InteractiveHoverButton } from "./magicui/interactive-hover-button"
|
import { InteractiveHoverButton } from "./magicui/interactive-hover-button"
|
||||||
import { NumberTicker } from "./magicui/number-ticker"
|
import { NumberTicker } from "./magicui/number-ticker"
|
||||||
@@ -35,7 +35,7 @@ interface IconCardProps {
|
|||||||
imageUrl: string
|
imageUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconCard({ name, imageUrl }: IconCardProps) {
|
function _IconCard({ name, imageUrl }: IconCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-4 flex flex-col items-center gap-2 cursor-pointer group hover-lift card-hover">
|
<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">
|
<div className="w-16 h-16 flex items-center justify-center">
|
||||||
@@ -52,7 +52,7 @@ function ElegantShape({
|
|||||||
width = 400,
|
width = 400,
|
||||||
height = 100,
|
height = 100,
|
||||||
rotate = 0,
|
rotate = 0,
|
||||||
gradient = "from-rose-500/[0.5]",
|
gradient = "from-primary/[0.5]",
|
||||||
mobileWidth,
|
mobileWidth,
|
||||||
mobileHeight,
|
mobileHeight,
|
||||||
}: {
|
}: {
|
||||||
@@ -128,10 +128,13 @@ function ElegantShape({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 rounded-full",
|
"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,
|
gradient,
|
||||||
"backdrop-blur-[3px]",
|
"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:absolute after:inset-0 after:rounded-full",
|
||||||
"after:bg-[radial-gradient(circle_at_50%_50%,rgba(255,255,255,0.4),transparent_70%)]",
|
"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 (
|
return (
|
||||||
<div className="relative w-full flex items-center justify-center overflow-hidden">
|
<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">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
<ElegantShape
|
<ElegantShape
|
||||||
@@ -156,7 +159,7 @@ export function HeroSection({ totalIcons, stars }: { totalIcons: number; stars:
|
|||||||
mobileWidth={300}
|
mobileWidth={300}
|
||||||
mobileHeight={80}
|
mobileHeight={80}
|
||||||
rotate={12}
|
rotate={12}
|
||||||
gradient="from-rose-500/[0.6]"
|
gradient="from-primary/[0.6]"
|
||||||
className="left-[-10%] md:left-[-5%] top-[15%] md:top-[20%]"
|
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}
|
mobileWidth={250}
|
||||||
mobileHeight={70}
|
mobileHeight={70}
|
||||||
rotate={-15}
|
rotate={-15}
|
||||||
gradient="from-rose-500/[0.55]"
|
gradient="from-primary/[0.55]"
|
||||||
className="right-[-5%] md:right-[0%] top-[70%] md:top-[75%]"
|
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}
|
mobileWidth={150}
|
||||||
mobileHeight={50}
|
mobileHeight={50}
|
||||||
rotate={-8}
|
rotate={-8}
|
||||||
gradient="from-rose-500/[0.65]"
|
gradient="from-primary/[0.65]"
|
||||||
className="left-[5%] md:left-[10%] bottom-[5%] md:bottom-[10%]"
|
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}
|
mobileWidth={100}
|
||||||
mobileHeight={40}
|
mobileHeight={40}
|
||||||
rotate={20}
|
rotate={20}
|
||||||
gradient="from-rose-500/[0.58]"
|
gradient="from-primary/[0.58]"
|
||||||
className="right-[15%] md:right-[20%] top-[10%] md:top-[15%]"
|
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}
|
mobileWidth={80}
|
||||||
mobileHeight={30}
|
mobileHeight={30}
|
||||||
rotate={-25}
|
rotate={-25}
|
||||||
gradient="from-rose-500/[0.62]"
|
gradient="from-primary/[0.62]"
|
||||||
className="left-[20%] md:left-[25%] top-[5%] md:top-[10%]"
|
className="left-[20%] md:left-[25%] top-[5%] md:top-[10%]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 ">
|
<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 ">
|
<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
|
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 />
|
<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>
|
<AuroraText colors={["#FA5352", "#FA5352", "orange"]}>dashboard icons</AuroraText>
|
||||||
</h1>
|
</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{" "}
|
A collection of{" "}
|
||||||
<NumberTicker value={totalIcons} startValue={1000} className="font-bold tracking-tighter text-muted-foreground" /> curated icons
|
<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.
|
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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h5 className="text-sm font-medium text-secondary-foreground">How your star helps us:</h5>
|
<h5 className="text-sm font-medium text-muted-foreground">How your star helps us:</h5>
|
||||||
<ul className="text-xs text-secondary-foreground/80 space-y-1.5">
|
<ul className="text-xs text-muted-foreground/80 space-y-1.5">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<TrendingUp className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
|
<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>
|
<span>Increases our visibility in GitHub search results</span>
|
||||||
@@ -307,7 +310,7 @@ export default function GiveUsAStarButton({ stars }: { stars: string | number })
|
|||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="sm"
|
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={() =>
|
onClick={() =>
|
||||||
window.open("https://docs.github.com/get-started/exploring-projects-on-github/saving-repositories-with-stars", "_blank")
|
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">
|
<Button variant="outline" className="h-9 md:h-10 px-4 cursor-pointer">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p>Give us love</p>
|
<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>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
@@ -337,7 +340,7 @@ export function GiveUsLoveButton() {
|
|||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="font-medium leading-none flex items-center gap-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
|
Support us without spending
|
||||||
</h4>
|
</h4>
|
||||||
<p className="text-sm text-muted-foreground">We keep our service free through minimal, non-intrusive ads.</p>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h5 className="text-sm font-medium text-secondary-foreground">Our Privacy Promise:</h5>
|
<h5 className="text-sm font-medium text-muted-foreground">Our Privacy Promise:</h5>
|
||||||
<ul className="text-xs text-secondary-foreground/80 space-y-1.5">
|
<ul className="text-xs text-muted-foreground/80 space-y-1.5">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<span className="text-primary font-bold">✓</span>
|
<span className="text-primary font-bold">✓</span>
|
||||||
<span>We don't track your browsing habits</span>
|
<span>We don't track your browsing habits</span>
|
||||||
@@ -377,11 +380,11 @@ export function GiveUsLoveButton() {
|
|||||||
<Separator className="bg-secondary/20" />
|
<Separator className="bg-secondary/20" />
|
||||||
|
|
||||||
<div className="space-y-2">
|
<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" />
|
<Share2 className="h-4 w-4 text-primary" />
|
||||||
Spread the word
|
Spread the word
|
||||||
</h5>
|
</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.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,8 +428,8 @@ export function GiveUsMoneyButton() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h5 className="text-sm font-medium text-secondary-foreground">Where your money goes:</h5>
|
<h5 className="text-sm font-medium text-muted-foreground">Where your money goes:</h5>
|
||||||
<ul className="text-xs text-secondary-foreground/80 space-y-1.5">
|
<ul className="text-xs text-muted-foreground/80 space-y-1.5">
|
||||||
<li className="flex items-start gap-2">
|
<li className="flex items-start gap-2">
|
||||||
<Server className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
|
<Server className="h-3.5 w-3.5 text-primary flex-shrink-0 mt-0.5" />
|
||||||
<span>Hosting and infrastructure costs</span>
|
<span>Hosting and infrastructure costs</span>
|
||||||
@@ -453,7 +456,7 @@ export function GiveUsMoneyButton() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`${openCollectiveUrl}/transactions`} target="_blank" rel="noopener noreferrer">
|
<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
|
View transactions
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Check, Copy, Download, Github, Link as LinkIcon } from "lucide-react"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import Link from "next/link"
|
||||||
import { Check, Copy, Download, Github, Link as LinkIcon } from "lucide-react";
|
import type React from "react"
|
||||||
import Link from "next/link";
|
import { Button } from "@/components/ui/button"
|
||||||
import type React from "react";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
|
|
||||||
export type IconActionsProps = {
|
export type IconActionsProps = {
|
||||||
imageUrl: string;
|
imageUrl: string
|
||||||
githubUrl: string;
|
githubUrl: string
|
||||||
iconName: string;
|
iconName: string
|
||||||
format: string;
|
format: string
|
||||||
variantKey: string;
|
variantKey: string
|
||||||
copiedUrlKey: string | null;
|
copiedUrlKey: string | null
|
||||||
copiedImageKey: string | null;
|
copiedImageKey: string | null
|
||||||
handleDownload: (event: React.MouseEvent, url: string, filename: string) => Promise<void>;
|
handleDownload: (event: React.MouseEvent, url: string, filename: string) => Promise<void>
|
||||||
handleCopyUrl: (url: string, variantKey: string, event?: React.MouseEvent) => void;
|
handleCopyUrl: (url: string, variantKey: string, event?: React.MouseEvent) => void
|
||||||
handleCopyImage: (imageUrl: string, format: string, variantKey: string, event?: React.MouseEvent) => Promise<void>;
|
handleCopyImage: (imageUrl: string, format: string, variantKey: string, event?: React.MouseEvent) => Promise<void>
|
||||||
};
|
}
|
||||||
|
|
||||||
export function IconActions({
|
export function IconActions({
|
||||||
imageUrl,
|
imageUrl,
|
||||||
@@ -29,9 +29,9 @@ export function IconActions({
|
|||||||
handleCopyUrl,
|
handleCopyUrl,
|
||||||
handleCopyImage,
|
handleCopyImage,
|
||||||
}: IconActionsProps) {
|
}: IconActionsProps) {
|
||||||
const downloadFilename = `${iconName}.${format}`;
|
const downloadFilename = `${iconName}.${format}`
|
||||||
const isUrlCopied = copiedUrlKey === variantKey;
|
const isUrlCopied = copiedUrlKey === variantKey
|
||||||
const isImageCopied = copiedImageKey === variantKey;
|
const isImageCopied = copiedImageKey === variantKey
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
@@ -64,11 +64,7 @@ export function IconActions({
|
|||||||
onClick={(e) => handleCopyImage(imageUrl, format, variantKey, e)}
|
onClick={(e) => handleCopyImage(imageUrl, format, variantKey, e)}
|
||||||
aria-label={`Copy ${iconName} image as ${format.toUpperCase()}`}
|
aria-label={`Copy ${iconName} image as ${format.toUpperCase()}`}
|
||||||
>
|
>
|
||||||
{isImageCopied ? (
|
{isImageCopied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||||
<Check className="w-4 h-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -86,11 +82,7 @@ export function IconActions({
|
|||||||
onClick={(e) => handleCopyUrl(imageUrl, variantKey, e)}
|
onClick={(e) => handleCopyUrl(imageUrl, variantKey, e)}
|
||||||
aria-label={`Copy direct URL for ${iconName} ${format.toUpperCase()}`}
|
aria-label={`Copy direct URL for ${iconName} ${format.toUpperCase()}`}
|
||||||
>
|
>
|
||||||
{isUrlCopied ? (
|
{isUrlCopied ? <Check className="w-4 h-4 text-green-500" /> : <LinkIcon className="w-4 h-4" />}
|
||||||
<Check className="w-4 h-4 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<LinkIcon className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -102,12 +94,7 @@ export function IconActions({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
|
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg" asChild>
|
||||||
<Link
|
<Link href={githubUrl} target="_blank" rel="noopener noreferrer" aria-label={`View ${iconName} ${format} file on GitHub`}>
|
||||||
href={githubUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`View ${iconName} ${format} file on GitHub`}
|
|
||||||
>
|
|
||||||
<Github className="w-4 h-4" />
|
<Github className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -118,5 +105,5 @@ export function IconActions({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,23 @@
|
|||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
import { MagicCard } from "@/components/magicui/magic-card"
|
import { MagicCard } from "@/components/magicui/magic-card"
|
||||||
import { BASE_URL } from "@/constants"
|
import { BASE_URL } from "@/constants"
|
||||||
import { formatIconName } from "@/lib/utils"
|
import { formatIconName } from "@/lib/utils"
|
||||||
import type { Icon } from "@/types/icons"
|
import type { Icon } from "@/types/icons"
|
||||||
import Image from "next/image"
|
|
||||||
import Link from "next/link"
|
|
||||||
|
|
||||||
export function IconCard({
|
export function IconCard({ name, data: iconData, matchedAlias }: { name: string; data: Icon; matchedAlias?: string }) {
|
||||||
name,
|
|
||||||
data: iconData,
|
|
||||||
matchedAlias,
|
|
||||||
}: {
|
|
||||||
name: string
|
|
||||||
data: Icon
|
|
||||||
matchedAlias?: string
|
|
||||||
}) {
|
|
||||||
const formatedIconName = formatIconName(name)
|
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 (
|
return (
|
||||||
<MagicCard className="rounded-md shadow-md">
|
<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">
|
<div className="relative h-16 w-16 mb-2">
|
||||||
<Image
|
<Image
|
||||||
src={`${BASE_URL}/${iconData.base}/${iconData.colors?.light || name}.${iconData.base}`}
|
src={imageUrl}
|
||||||
alt={`${name} icon`}
|
alt={`${name} icon`}
|
||||||
fill
|
fill
|
||||||
className="object-contain p-1 group-hover:scale-110 transition-transform duration-300"
|
className="object-contain p-1 group-hover:scale-110 transition-transform duration-300"
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
"use client"
|
"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 confetti from "canvas-confetti"
|
||||||
import { motion } from "framer-motion"
|
import { motion } from "framer-motion"
|
||||||
import { ArrowRight, Check, FileType, Github, Moon, PaletteIcon, Sun, Type } from "lucide-react"
|
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 type React from "react"
|
||||||
import { useCallback, useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { toast } from "sonner"
|
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 { Carbon } from "./carbon"
|
||||||
import { IconActions } from "./icon-actions"
|
import { IconActions } from "./icon-actions"
|
||||||
import { MagicCard } from "./magicui/magic-card"
|
import { MagicCard } from "./magicui/magic-card"
|
||||||
import { Badge } from "./ui/badge"
|
import { Badge } from "./ui/badge"
|
||||||
|
|
||||||
type RenderVariantFn = (
|
type RenderVariantFn = (format: string, iconName: string, theme?: "light" | "dark") => React.ReactNode
|
||||||
format: string,
|
|
||||||
iconName: string,
|
|
||||||
theme?: "light" | "dark"
|
|
||||||
) => React.ReactNode
|
|
||||||
|
|
||||||
type IconVariantsSectionProps = {
|
type IconVariantsSectionProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -76,11 +72,7 @@ type WordmarkSectionProps = {
|
|||||||
renderVariant: RenderVariantFn
|
renderVariant: RenderVariantFn
|
||||||
}
|
}
|
||||||
|
|
||||||
function WordmarkSection({
|
function WordmarkSection({ iconData, aavailableFormats, renderVariant }: WordmarkSectionProps) {
|
||||||
iconData,
|
|
||||||
aavailableFormats,
|
|
||||||
renderVariant,
|
|
||||||
}: WordmarkSectionProps) {
|
|
||||||
if (!iconData.wordmark) return null
|
if (!iconData.wordmark) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -89,9 +81,7 @@ function WordmarkSection({
|
|||||||
<Type className="w-4 h-4 text-green-500" />
|
<Type className="w-4 h-4 text-green-500" />
|
||||||
Wordmark Variants
|
Wordmark Variants
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">Icon variants that include the brand name. Click to copy URL.</p>
|
||||||
Icon variants that include the brand name. Click to copy URL.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{iconData.wordmark.light && (
|
{iconData.wordmark.light && (
|
||||||
<div>
|
<div>
|
||||||
@@ -135,8 +125,8 @@ export type IconDetailsProps = {
|
|||||||
|
|
||||||
export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetailsProps) {
|
export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetailsProps) {
|
||||||
const authorName = authorData.name || authorData.login || ""
|
const authorName = authorData.name || authorData.login || ""
|
||||||
const iconColorVariants = iconData.colors
|
const _iconColorVariants = iconData.colors
|
||||||
const iconWordmarkVariants = iconData.wordmark
|
const _iconWordmarkVariants = iconData.wordmark
|
||||||
const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", {
|
const formattedDate = new Date(iconData.update.timestamp).toLocaleDateString("en-GB", {
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
@@ -155,7 +145,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
|||||||
}
|
}
|
||||||
|
|
||||||
const availableFormats = getAvailableFormats()
|
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 [copiedUrlKey, setCopiedUrlKey] = useState<string | null>(null)
|
||||||
const [copiedImageKey, setCopiedImageKey] = 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 (
|
const handleCopyImage = async (imageUrl: string, format: string, variantKey: string, event?: React.MouseEvent) => {
|
||||||
imageUrl: string,
|
|
||||||
format: string,
|
|
||||||
variantKey: string,
|
|
||||||
event?: React.MouseEvent
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
toast.loading("Copying image...")
|
toast.loading("Copying image...")
|
||||||
|
|
||||||
if (format === 'svg') {
|
if (format === "svg") {
|
||||||
const response = await fetch(imageUrl)
|
const response = await fetch(imageUrl)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch SVG: ${response.statusText}`)
|
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", {
|
toast.success("SVG Markup Copied", {
|
||||||
description: "The SVG code has been copied to your clipboard.",
|
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 mimeType = `image/${format}`
|
||||||
const response = await fetch(imageUrl)
|
const response = await fetch(imageUrl)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -250,10 +234,10 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
|||||||
const blob = await response.blob()
|
const blob = await response.blob()
|
||||||
|
|
||||||
if (!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)
|
setCopiedImageKey(variantKey)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -270,11 +254,9 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
|||||||
toast.success("Image copied", {
|
toast.success("Image copied", {
|
||||||
description: `The ${format.toUpperCase()} image has been copied to your clipboard.`,
|
description: `The ${format.toUpperCase()} image has been copied to your clipboard.`,
|
||||||
})
|
})
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported format for image copy: ${format}`)
|
throw new Error(`Unsupported format for image copy: ${format}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Copy error:", error)
|
console.error("Copy error:", error)
|
||||||
toast.dismiss()
|
toast.dismiss()
|
||||||
@@ -445,9 +427,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
|||||||
{authorName}
|
{authorName}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{!authorData.html_url && (
|
{!authorData.html_url && <span className="text-sm">{authorName}</span>}
|
||||||
<span className="text-sm">{authorName}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -696,6 +676,7 @@ export function IconDetails({ icon, iconData, authorData, allIcons }: IconDetail
|
|||||||
<Card className="bg-background/50 border shadow-lg">
|
<Card className="bg-background/50 border shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
|
{/** biome-ignore lint/correctness/useUniqueElementIds: I want the ID to be fixed */}
|
||||||
<h2 id="related-icons-title">Related Icons</h2>
|
<h2 id="related-icons-title">Related Icons</h2>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Icon } from "@/types/icons"
|
|
||||||
|
|
||||||
import { useWindowVirtualizer } from "@tanstack/react-virtual"
|
import { useWindowVirtualizer } from "@tanstack/react-virtual"
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import type { Icon } from "@/types/icons"
|
||||||
import { IconCard } from "./icon-card"
|
import { IconCard } from "./icon-card"
|
||||||
|
|
||||||
interface IconsGridProps {
|
interface IconsGridProps {
|
||||||
@@ -9,9 +8,19 @@ interface IconsGridProps {
|
|||||||
matchedAliases: Record<string, string>
|
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) {
|
export function IconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
|
||||||
return (
|
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 }) => (
|
{filteredIcons.slice(0, 120).map(({ name, data }) => (
|
||||||
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name]} />
|
<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) {
|
export function VirtualizedIconsGrid({ filteredIcons, matchedAliases }: IconsGridProps) {
|
||||||
const listRef = useRef<HTMLDivElement | null>(null)
|
const listRef = useRef<HTMLDivElement | null>(null)
|
||||||
const [windowWidth, setWindowWidth] = useState(0)
|
const [windowWidth, setWindowWidth] = useState(0)
|
||||||
@@ -73,7 +86,7 @@ export function VirtualizedIconsGrid({ filteredIcons, matchedAliases }: IconsGri
|
|||||||
width: "100%",
|
width: "100%",
|
||||||
transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
|
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 }) => (
|
{rowIcons.map(({ name, data }) => (
|
||||||
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name]} />
|
<IconCard key={name} name={name} data={data} matchedAlias={matchedAliases[name]} />
|
||||||
|
|||||||
124
web/src/components/icon-name-combobox.tsx
Normal file
124
web/src/components/icon-name-combobox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
"use client"
|
"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 { VirtualizedIconsGrid } from "@/components/icon-grid"
|
||||||
import { IconSubmissionContent } from "@/components/icon-submission-form"
|
import { IconSubmissionContent } from "@/components/icon-submission-form"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
@@ -17,14 +23,8 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Separator } from "@/components/ui/separator"
|
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 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) {
|
export function IconSearch({ icons }: IconSearchProps) {
|
||||||
const searchParams = useSearchParams()
|
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>
|
<p className="text-lg text-muted-foreground mt-2">Help us expand our collection</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 items-center w-full">
|
<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">
|
<div id="icon-submission-content" className="w-full">
|
||||||
<IconSubmissionContent />
|
<IconSubmissionContent />
|
||||||
</div>
|
</div>
|
||||||
@@ -387,7 +388,7 @@ export function IconSearch({ icons }: IconSearchProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between items-center pb-2">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Found {filteredIcons.length} icon
|
Found {filteredIcons.length} icon
|
||||||
{filteredIcons.length !== 1 ? "s" : ""}.
|
{filteredIcons.length !== 1 ? "s" : ""}.
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client"
|
"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 { DialogDescription } from "@radix-ui/react-dialog"
|
||||||
import { ExternalLink, PlusCircle } from "lucide-react"
|
import { ExternalLink, PlusCircle } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useState } from "react"
|
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 = [
|
export const ISSUE_TEMPLATES = [
|
||||||
{
|
{
|
||||||
@@ -41,8 +42,62 @@ export const ISSUE_TEMPLATES = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
export function IconSubmissionContent({ onClose }: { onClose?: () => void }) {
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<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">
|
<div className="flex flex-col gap-2">
|
||||||
{ISSUE_TEMPLATES.map((template) => (
|
{ISSUE_TEMPLATES.map((template) => (
|
||||||
<Link key={template.id} href={template.url} className="w-full group z10" target="_blank" rel="noopener noreferrer">
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
export function IconSubmissionForm({ trigger }: { trigger?: React.ReactNode }) {
|
export function IconSubmissionForm({ trigger, onClick }: { trigger?: React.ReactNode, onClick?: () => void }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -74,7 +129,7 @@ export function IconSubmissionForm({ trigger }: { trigger?: React.ReactNode }) {
|
|||||||
{trigger ? (
|
{trigger ? (
|
||||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
<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">
|
<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)
|
<PlusCircle className="h-4 w-4 transition-all duration-300" /> Submit icon(s)
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { REPO_PATH } from "@/constants"
|
|
||||||
import { AnimatePresence, motion } from "framer-motion"
|
import { AnimatePresence, motion } from "framer-motion"
|
||||||
import { X } from "lucide-react"
|
import { X } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { REPO_PATH } from "@/constants"
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = "licenseNoticeDismissed"
|
const LOCAL_STORAGE_KEY = "licenseNoticeDismissed"
|
||||||
|
|
||||||
|
|||||||
277
web/src/components/login-modal.tsx
Normal file
277
web/src/components/login-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ArrowRight } from "lucide-react"
|
import { ArrowRight } from "lucide-react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
|
|
||||||
interface InteractiveHoverButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
interface InteractiveHoverButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { motion, useMotionTemplate, useMotionValue } from "motion/react"
|
import { motion, useMotionTemplate, useMotionValue } from "motion/react"
|
||||||
import type React from "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 { cn } from "@/lib/utils"
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
|
||||||
interface MagicCardProps {
|
interface MagicCardProps {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
@@ -29,6 +30,7 @@ export function MagicCard({
|
|||||||
const mouseX = useMotionValue(-gradientSize)
|
const mouseX = useMotionValue(-gradientSize)
|
||||||
const mouseY = useMotionValue(-gradientSize)
|
const mouseY = useMotionValue(-gradientSize)
|
||||||
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (cardRef.current) {
|
if (cardRef.current) {
|
||||||
@@ -76,6 +78,21 @@ export function MagicCard({
|
|||||||
mouseY.set(-gradientSize)
|
mouseY.set(-gradientSize)
|
||||||
}, [gradientSize, mouseX, mouseY])
|
}, [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 (
|
return (
|
||||||
<div ref={cardRef} className={cn("group relative rounded-[inherit]", className)}>
|
<div ref={cardRef} className={cn("group relative rounded-[inherit]", className)}>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -83,8 +100,8 @@ export function MagicCard({
|
|||||||
style={{
|
style={{
|
||||||
background: useMotionTemplate`
|
background: useMotionTemplate`
|
||||||
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
|
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
|
||||||
${gradientFrom},
|
${fromColor},
|
||||||
${gradientTo},
|
${toColor},
|
||||||
var(--border) 100%
|
var(--border) 100%
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
import type { ComponentPropsWithoutRef } from "react"
|
import type { ComponentPropsWithoutRef } from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface MarqueeProps extends ComponentPropsWithoutRef<"div"> {
|
interface MarqueeProps extends ComponentPropsWithoutRef<"div"> {
|
||||||
/**
|
/**
|
||||||
|
|||||||
26
web/src/components/providers.tsx
Normal file
26
web/src/components/providers.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client"
|
"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 { format, isToday, isYesterday } from "date-fns"
|
||||||
import { ArrowRight, Clock, ExternalLink } from "lucide-react"
|
import { ArrowRight, Clock, ExternalLink } from "lucide-react"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import Link from "next/link"
|
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 {
|
function formatIconDate(timestamp: string): string {
|
||||||
const date = new Date(timestamp)
|
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 px-6 lg:px-8">
|
||||||
<div className="mx-auto max-w-2xl text-center my-4">
|
<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
|
Recently Added Icons
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,13 +71,7 @@ export function RecentlyAddedIcons({ icons }: { icons: IconWithName[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Marquee-compatible icon card
|
// Marquee-compatible icon card
|
||||||
function RecentIconCard({
|
function RecentIconCard({ name, data }: { name: string; data: Icon }) {
|
||||||
name,
|
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
name: string
|
|
||||||
data: Icon
|
|
||||||
}) {
|
|
||||||
const formattedIconName = formatIconName(name)
|
const formattedIconName = formatIconName(name)
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -85,12 +79,12 @@ function RecentIconCard({
|
|||||||
href={`/icons/${name}`}
|
href={`/icons/${name}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center p-3 sm:p-4 rounded-xl border border-border",
|
"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",
|
"w-36 mx-2 group/item",
|
||||||
)}
|
)}
|
||||||
aria-label={`View details for ${formattedIconName} icon`}
|
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">
|
<div className="relative h-12 w-12 sm:h-16 sm:w-16 mb-2">
|
||||||
<Image
|
<Image
|
||||||
@@ -100,7 +94,7 @@ function RecentIconCard({
|
|||||||
className="object-contain p-1 hover:scale-110 transition-transform duration-300"
|
className="object-contain p-1 hover:scale-110 transition-transform duration-300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
{formattedIconName}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center justify-center mt-2 w-full">
|
<div className="flex items-center justify-center mt-2 w-full">
|
||||||
|
|||||||
404
web/src/components/submission-details.tsx
Normal file
404
web/src/components/submission-details.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
441
web/src/components/submissions-data-table.tsx
Normal file
441
web/src/components/submissions-data-table.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
import { Moon, Sun } from "lucide-react"
|
import { Moon, Sun } from "lucide-react"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
|
import { useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
export function ThemeSwitcher() {
|
export function ThemeSwitcher() {
|
||||||
const { setTheme } = useTheme()
|
const { setTheme } = useTheme()
|
||||||
|
|||||||
107
web/src/components/ui/border-beam.tsx
Normal file
107
web/src/components/ui/border-beam.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import * as React from "react"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function CommandInput({
|
|||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
data-slot="command-input"
|
data-slot="command-input"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ function DialogOverlay({
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -49,24 +49,32 @@ function DialogOverlay({
|
|||||||
function DialogContent({
|
function DialogContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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 />
|
<XIcon />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
)
|
)
|
||||||
@@ -131,6 +139,5 @@ export {
|
|||||||
DialogOverlay,
|
DialogOverlay,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger
|
DialogTrigger,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1183
web/src/components/ui/multi-select.tsx
Normal file
1183
web/src/components/ui/multi-select.tsx
Normal file
File diff suppressed because it is too large
Load Diff
311
web/src/components/ui/shadcn-io/combobox/index.tsx
Normal file
311
web/src/components/ui/shadcn-io/combobox/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
202
web/src/components/ui/shadcn-io/dropzone/index.tsx
Normal file
202
web/src/components/ui/shadcn-io/dropzone/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
298
web/src/components/user-button.tsx
Normal file
298
web/src/components/user-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
web/src/components/user-display.tsx
Normal file
57
web/src/components/user-display.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
web/src/hooks/use-posthog-auth.ts
Normal file
42
web/src/hooks/use-posthog-auth.ts
Normal 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])
|
||||||
|
}
|
||||||
170
web/src/hooks/use-submissions.ts
Normal file
170
web/src/hooks/use-submissions.ts
Normal 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
78
web/src/lib/community.ts
Normal 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
71
web/src/lib/pb.ts
Normal 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
|
||||||
45
web/src/lib/posthog-utils.ts
Normal file
45
web/src/lib/posthog-utils.ts
Normal 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
21
web/src/lib/revalidate.ts
Normal 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")
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IconWithName } from "@/types/icons"
|
|
||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import type { IconWithName } from "@/types/icons"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
|
|||||||
Reference in New Issue
Block a user