From 0a81369371aa0b818af5852b3ae6a40b60135108 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 7 Jul 2025 22:48:04 +1000 Subject: [PATCH 01/14] refactor navbar structure and hero section --- dev-share-ui/app/layout.tsx | 9 +- dev-share-ui/app/search/page.tsx | 182 +++-- dev-share-ui/app/share/page.tsx | 154 ++-- dev-share-ui/components/HeroSection.tsx | 83 +- dev-share-ui/components/Navbar.tsx | 32 +- dev-share-ui/components/magicui/particles.tsx | 313 +++++++ dev-share-ui/components/ui/sidebar.tsx | 773 ++++++++++++++++++ dev-share-ui/hooks/use-mobile.tsx | 19 + 8 files changed, 1360 insertions(+), 205 deletions(-) create mode 100644 dev-share-ui/components/magicui/particles.tsx create mode 100644 dev-share-ui/components/ui/sidebar.tsx create mode 100644 dev-share-ui/hooks/use-mobile.tsx diff --git a/dev-share-ui/app/layout.tsx b/dev-share-ui/app/layout.tsx index d5f74fa..51d07d1 100644 --- a/dev-share-ui/app/layout.tsx +++ b/dev-share-ui/app/layout.tsx @@ -3,12 +3,14 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import { ThemeProvider } from '@/components/ThemeProvider'; import { Toaster } from 'sonner'; +import Navbar from '@/components/Navbar'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: 'blotz dev share', - description: 'A place to discover and share the best developer resources with the community', + description: + 'A place to discover and share the best developer resources with the community', }; export default function RootLayout({ @@ -25,10 +27,11 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {children} + +
{children}
); -} \ No newline at end of file +} diff --git a/dev-share-ui/app/search/page.tsx b/dev-share-ui/app/search/page.tsx index a4e9bb5..14ac29f 100644 --- a/dev-share-ui/app/search/page.tsx +++ b/dev-share-ui/app/search/page.tsx @@ -1,69 +1,72 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import Navbar from "@/components/Navbar"; -import HeroSection from "@/components/HeroSection"; -import ResourceGrid from "@/components/ResourceGrid"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { Plus } from "lucide-react"; -import { mockResources } from "@/lib/data"; -import { Resource, VectorSearchResultDTO } from "@/lib/types"; -import EmptyState from "@/components/EmptyState"; -import { Switch } from "@/components/ui/switch"; -import ResourceCard from "@/components/ResourceCard"; -import FromAISearchResourceCard from "@/components/FromAISearchResourceCard"; +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; + +import HeroSection from '@/components/HeroSection'; +import ResourceGrid from '@/components/ResourceGrid'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Plus } from 'lucide-react'; +import { mockResources } from '@/lib/data'; +import { Resource, VectorSearchResultDTO } from '@/lib/types'; +import EmptyState from '@/components/EmptyState'; +import { Switch } from '@/components/ui/switch'; +import ResourceCard from '@/components/ResourceCard'; +import FromAISearchResourceCard from '@/components/FromAISearchResourceCard'; export default function SearchPage() { const [resources, setResources] = useState(mockResources); const [isSearching, setIsSearching] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(''); const [showEmpty, setShowEmpty] = useState(false); const topRelative = 6; //TODO: Test if search still working with the backend API //TODO: Move search logic to a service folder - const searchResources = async(query: string) : Promise=> { - const result = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL_WITH_API}/search`,{ - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - text: query, - topRelatives: topRelative - }), - }) + const searchResources = async (query: string): Promise => { + const result = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL_WITH_API}/search`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + text: query, + topRelatives: topRelative, + }), + } + ); if (!result.ok) throw new Error(`Search failed (${result.status})`); const dtos: VectorSearchResultDTO[] = await result.json(); - return dtos.map(dto => ({ - id: crypto.randomUUID(), // or hash(dto.url) - title: dto.content.slice(0, 80), // quick placeholder - description: dto.content, - url: dto.url, - imageUrl: "", // TODO: fetch or derive - tags: [], - likes: 0, - date: new Date().toISOString(), - comment: "", - isLiked: false, - isBookmarked: false, - recommended: false, - authorName: "Unknown", - authorAvatar: "https://avatar.iran.liara.run/public/boy", - linkClicks: 0, - createdAt: new Date().toISOString() + return dtos.map((dto) => ({ + id: crypto.randomUUID(), // or hash(dto.url) + title: dto.content.slice(0, 80), // quick placeholder + description: dto.content, + url: dto.url, + imageUrl: '', // TODO: fetch or derive + tags: [], + likes: 0, + date: new Date().toISOString(), + comment: '', + isLiked: false, + isBookmarked: false, + recommended: false, + authorName: 'Unknown', + authorAvatar: 'https://avatar.iran.liara.run/public/boy', + linkClicks: 0, + createdAt: new Date().toISOString(), })); }; const handleSearch = async (query: string) => { setIsSearching(true); setSearchQuery(query); - + const result = await searchResources(query); setResources(result); @@ -76,12 +79,16 @@ export default function SearchPage() { // 1. Send user action to your API // 2. Update resource in your database // 3. Use this data to improve recommendations - - setResources(prevResources => - prevResources.map(resource => { + + setResources((prevResources) => + prevResources.map((resource) => { if (resource.id === id) { if (action === 'like') { - return { ...resource, likes: resource.isLiked ? resource.likes - 1 : resource.likes + 1, isLiked: !resource.isLiked }; + return { + ...resource, + likes: resource.isLiked ? resource.likes - 1 : resource.likes + 1, + isLiked: !resource.isLiked, + }; } else { return { ...resource, isBookmarked: !resource.isBookmarked }; } @@ -90,7 +97,7 @@ export default function SearchPage() { }) ); }; - + const handleSearchSuggestion = (suggestion: string) => { handleSearch(suggestion); }; @@ -99,42 +106,53 @@ export default function SearchPage() { // For testing: show a single AI search resource setResources([ { - id: "ai-1", - title: "Awesome React Libraries (AI Search)", - description: "Curated list of trending React libraries and tools, generated by SuperPro AI global search.", - url: "https://github.com/ai/react-libraries", - imageUrl: "", - tags: ["React", "AI", "Trending"], + id: 'ai-1', + title: 'Awesome React Libraries (AI Search)', + description: + 'Curated list of trending React libraries and tools, generated by SuperPro AI global search.', + url: 'https://github.com/ai/react-libraries', + imageUrl: '', + tags: ['React', 'AI', 'Trending'], likes: 0, date: new Date().toISOString(), isLiked: false, isBookmarked: false, recommended: false, - authorName: "SuperPro AI", - authorAvatar: "https://api.dicebear.com/7.x/bottts/svg?seed=ai", + authorName: 'SuperPro AI', + authorAvatar: 'https://api.dicebear.com/7.x/bottts/svg?seed=ai', linkClicks: 0, createdAt: new Date().toISOString(), - comment: "This list was generated by SuperPro AI global search. Enjoy exploring the latest in React!", - isAIGenerated: true - } + comment: + 'This list was generated by SuperPro AI global search. Enjoy exploring the latest in React!', + isAIGenerated: true, + }, ]); }; return ( -
- +
- +
- {resources.length} resources found + + {resources.length} resources found +
- View All + + View All +
{/* TODO: remove showEmpty */} {showEmpty ? ( @@ -145,16 +163,26 @@ export default function SearchPage() { /> ) : (
- {resources.sort((a, b) => b.likes - a.likes).map((resource, idx) => - resource.isAIGenerated ? ( - - ) : ( - - ) - )} + {resources + .sort((a, b) => b.likes - a.likes) + .map((resource, idx) => + resource.isAIGenerated ? ( + + ) : ( + + ) + )}
)}
-
+ ); -} \ No newline at end of file +} diff --git a/dev-share-ui/app/share/page.tsx b/dev-share-ui/app/share/page.tsx index c0dfeeb..8d5fc11 100644 --- a/dev-share-ui/app/share/page.tsx +++ b/dev-share-ui/app/share/page.tsx @@ -1,12 +1,12 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { Link as LinkIcon, MessageSquare } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Link as LinkIcon, MessageSquare } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; import { Card, CardContent, @@ -14,37 +14,36 @@ import { CardFooter, CardHeader, CardTitle, -} from "@/components/ui/card"; -import { toast } from "sonner"; -import Navbar from "@/components/Navbar"; +} from '@/components/ui/card'; +import { toast } from 'sonner'; export default function ShareResourcePage() { - const [url, setUrl] = useState(""); - const [comment, setComment] = useState(""); + const [url, setUrl] = useState(''); + const [comment, setComment] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - const [urlError, setUrlError] = useState(""); + const [urlError, setUrlError] = useState(''); const router = useRouter(); const validateUrl = (url: string) => { try { new URL(url); - setUrlError(""); + setUrlError(''); return true; } catch (e) { - setUrlError("Please enter a valid URL (including http:// or https://)"); + setUrlError('Please enter a valid URL (including http:// or https://)'); return false; } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - + if (!validateUrl(url)) { return; } - + setIsSubmitting(true); - + // TODO: Integration point for AI processing // 1. Send URL and prompt to backend // 2. Backend will: @@ -53,100 +52,107 @@ export default function ShareResourcePage() { // - Store the processed resource // Example: // const resource = await processResourceWithAI({ url, prompt }); - + // Simulate API call //set the max processing time const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 6000); //TODO: Move this to "share" service - try{ - const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL_WITH_API}/share`,{ - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - url: url, - comment: comment - }), - signal: controller.signal, - }); + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL_WITH_API}/share`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: url, + comment: comment, + }), + signal: controller.signal, + } + ); clearTimeout(timeoutId); - - if(!res.ok){ - toast.error("Uh oh! Something went wrong. Please try again later."); + + if (!res.ok) { + toast.error('Uh oh! Something went wrong. Please try again later.'); throw new Error('Request failed'); } const data = await res.json(); const taskId = data.taskId; - toast.success("Processing resource...please check status later", {duration: 2500}); - + toast.success('Processing resource...please check status later', { + duration: 2500, + }); + //polling start let pollCount = 0; - const maxPolls = 5; + const maxPolls = 5; const pollInterval = 3000; const pollStatus = async () => { pollCount++; - + try { - const statusRes = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL_WITH_API}/share/status/${taskId}`); + const statusRes = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL_WITH_API}/share/status/${taskId}` + ); const statusData = await statusRes.json(); - - if (statusData.status === "success") { - toast.success("Resource processing completed!"); - router.push("/"); + + if (statusData.status === 'success') { + toast.success('Resource processing completed!'); + router.push('/'); return; - } else if (statusData.status === "failed") { - toast.error("Resource processing failed."); + } else if (statusData.status === 'failed') { + toast.error('Resource processing failed.'); return; - } else if (statusData.status === "pending") { + } else if (statusData.status === 'pending') { console.log(`Polling #${pollCount}: still pending...`); } - + if (pollCount >= maxPolls) { - toast.error("Resource processing timed out."); + toast.error('Resource processing timed out.'); return; } - + setTimeout(pollStatus, pollInterval); - } catch (err) { - toast.error("Polling error occurred."); + toast.error('Polling error occurred.'); } }; pollStatus(); - - }catch(error){ + } catch (error) { const err = error as Error; if (err.name === 'AbortError') { - toast.error("Request timed out after 5 seconds."); + toast.error('Request timed out after 5 seconds.'); } else { - toast.error("An unexpected error occurred."); + toast.error('An unexpected error occurred.'); } } setIsSubmitting(false); - router.push("/"); + router.push('/'); }; return ( -
- +
Share a Resource - Share a valuable developer resource. Our AI will analyze it and add relevant details. + Share a valuable developer resource. Our AI will analyze it and + add relevant details.
- +
- {urlError &&

{urlError}

} + {urlError && ( +

{urlError}

+ )}
- -
-
+ ); -} \ No newline at end of file +} diff --git a/dev-share-ui/components/HeroSection.tsx b/dev-share-ui/components/HeroSection.tsx index 3e0299d..2f4322c 100644 --- a/dev-share-ui/components/HeroSection.tsx +++ b/dev-share-ui/components/HeroSection.tsx @@ -1,35 +1,44 @@ -"use client"; +'use client'; -import { useState, useEffect } from "react"; -import { Search, Plus } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import Link from "next/link"; +import { useState, useEffect } from 'react'; +import { Search, Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Particles } from '@/components/magicui/particles'; +import { useTheme } from 'next-themes'; interface HeroSectionProps { onSearch: (query: string) => void; isSearching: boolean; } -export default function HeroSection({ onSearch, isSearching }: HeroSectionProps) { - const [query, setQuery] = useState(""); +export default function HeroSection({ + onSearch, + isSearching, +}: HeroSectionProps) { + const [query, setQuery] = useState(''); const [placeholderIndex, setPlaceholderIndex] = useState(0); - + const { resolvedTheme } = useTheme(); + const [color, setColor] = useState('#ffffff'); + + useEffect(() => { + setColor(resolvedTheme === 'dark' ? '#ffffff' : '#000000'); + }, [resolvedTheme]); const placeholders = [ - "Search resources, tools, guides...", - "TypeScript tutorials for beginners", - "How to optimize Next.js performance", - "GraphQL resources for frontend developers", - "Learn CSS Grid and Flexbox", - "Node.js backend architecture patterns" + 'Search resources, tools, guides...', + 'TypeScript tutorials for beginners', + 'How to optimize Next.js performance', + 'GraphQL resources for frontend developers', + 'Learn CSS Grid and Flexbox', + 'Node.js backend architecture patterns', ]; - + // Rotate placeholder text every 5 seconds useEffect(() => { const interval = setInterval(() => { setPlaceholderIndex((prev) => (prev + 1) % placeholders.length); }, 5000); - + return () => clearInterval(interval); }, [placeholders.length]); @@ -41,30 +50,34 @@ export default function HeroSection({ onSearch, isSearching }: HeroSectionProps) }; return ( -
-
-
-
-
-

Discover Resources

-

Find and share valuable community resources

+
+ +
+
+
+
+

+ Discover Resources +

+

+ Find and share valuable community resources +

-
-
-
- + +
+ setQuery(e.target.value)} - className="pl-10 h-11 rounded-lg bg-card transition-all duration-300 focus:ring-2 focus:ring-primary/20 w-full" + className="pl-10 h-11 rounded-lg bg-card transition-all duration-300 focus:ring-2 focus:ring-primary/20 w-3/5" disabled={isSearching} />
@@ -73,4 +86,4 @@ export default function HeroSection({ onSearch, isSearching }: HeroSectionProps)
); -} \ No newline at end of file +} diff --git a/dev-share-ui/components/Navbar.tsx b/dev-share-ui/components/Navbar.tsx index 4f8430e..0f85992 100644 --- a/dev-share-ui/components/Navbar.tsx +++ b/dev-share-ui/components/Navbar.tsx @@ -1,20 +1,20 @@ -"use client"; +'use client'; -import Link from "next/link"; -import { BookMarked } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import Link from 'next/link'; +import { BookMarked } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { ThemeToggle } from "@/components/ThemeToggle"; +} from '@/components/ui/dropdown-menu'; +import { ThemeToggle } from '@/components/ThemeToggle'; export default function Navbar() { return ( -
-
+
+
@@ -22,22 +22,22 @@ export default function Navbar() {
-
); -} \ No newline at end of file +} diff --git a/dev-share-ui/components/magicui/particles.tsx b/dev-share-ui/components/magicui/particles.tsx new file mode 100644 index 0000000..ffba741 --- /dev/null +++ b/dev-share-ui/components/magicui/particles.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import React, { + ComponentPropsWithoutRef, + useEffect, + useRef, + useState, +} from "react"; + +interface MousePosition { + x: number; + y: number; +} + +function MousePosition(): MousePosition { + const [mousePosition, setMousePosition] = useState({ + x: 0, + y: 0, + }); + + useEffect(() => { + const handleMouseMove = (event: MouseEvent) => { + setMousePosition({ x: event.clientX, y: event.clientY }); + }; + + window.addEventListener("mousemove", handleMouseMove); + + return () => { + window.removeEventListener("mousemove", handleMouseMove); + }; + }, []); + + return mousePosition; +} + +interface ParticlesProps extends ComponentPropsWithoutRef<"div"> { + className?: string; + quantity?: number; + staticity?: number; + ease?: number; + size?: number; + refresh?: boolean; + color?: string; + vx?: number; + vy?: number; +} + +function hexToRgb(hex: string): number[] { + hex = hex.replace("#", ""); + + if (hex.length === 3) { + hex = hex + .split("") + .map((char) => char + char) + .join(""); + } + + const hexInt = parseInt(hex, 16); + const red = (hexInt >> 16) & 255; + const green = (hexInt >> 8) & 255; + const blue = hexInt & 255; + return [red, green, blue]; +} + +type Circle = { + x: number; + y: number; + translateX: number; + translateY: number; + size: number; + alpha: number; + targetAlpha: number; + dx: number; + dy: number; + magnetism: number; +}; + +export const Particles: React.FC = ({ + className = "", + quantity = 100, + staticity = 50, + ease = 50, + size = 0.4, + refresh = false, + color = "#ffffff", + vx = 0, + vy = 0, + ...props +}) => { + const canvasRef = useRef(null); + const canvasContainerRef = useRef(null); + const context = useRef(null); + const circles = useRef([]); + const mousePosition = MousePosition(); + const mouse = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const canvasSize = useRef<{ w: number; h: number }>({ w: 0, h: 0 }); + const dpr = typeof window !== "undefined" ? window.devicePixelRatio : 1; + const rafID = useRef(null); + const resizeTimeout = useRef(null); + + useEffect(() => { + if (canvasRef.current) { + context.current = canvasRef.current.getContext("2d"); + } + initCanvas(); + animate(); + + const handleResize = () => { + if (resizeTimeout.current) { + clearTimeout(resizeTimeout.current); + } + resizeTimeout.current = setTimeout(() => { + initCanvas(); + }, 200); + }; + + window.addEventListener("resize", handleResize); + + return () => { + if (rafID.current != null) { + window.cancelAnimationFrame(rafID.current); + } + if (resizeTimeout.current) { + clearTimeout(resizeTimeout.current); + } + window.removeEventListener("resize", handleResize); + }; + }, [color]); + + useEffect(() => { + onMouseMove(); + }, [mousePosition.x, mousePosition.y]); + + useEffect(() => { + initCanvas(); + }, [refresh]); + + const initCanvas = () => { + resizeCanvas(); + drawParticles(); + }; + + const onMouseMove = () => { + if (canvasRef.current) { + const rect = canvasRef.current.getBoundingClientRect(); + const { w, h } = canvasSize.current; + const x = mousePosition.x - rect.left - w / 2; + const y = mousePosition.y - rect.top - h / 2; + const inside = x < w / 2 && x > -w / 2 && y < h / 2 && y > -h / 2; + if (inside) { + mouse.current.x = x; + mouse.current.y = y; + } + } + }; + + const resizeCanvas = () => { + if (canvasContainerRef.current && canvasRef.current && context.current) { + canvasSize.current.w = canvasContainerRef.current.offsetWidth; + canvasSize.current.h = canvasContainerRef.current.offsetHeight; + + canvasRef.current.width = canvasSize.current.w * dpr; + canvasRef.current.height = canvasSize.current.h * dpr; + canvasRef.current.style.width = `${canvasSize.current.w}px`; + canvasRef.current.style.height = `${canvasSize.current.h}px`; + context.current.scale(dpr, dpr); + + // Clear existing particles and create new ones with exact quantity + circles.current = []; + for (let i = 0; i < quantity; i++) { + const circle = circleParams(); + drawCircle(circle); + } + } + }; + + const circleParams = (): Circle => { + const x = Math.floor(Math.random() * canvasSize.current.w); + const y = Math.floor(Math.random() * canvasSize.current.h); + const translateX = 0; + const translateY = 0; + const pSize = Math.floor(Math.random() * 2) + size; + const alpha = 0; + const targetAlpha = parseFloat((Math.random() * 0.6 + 0.1).toFixed(1)); + const dx = (Math.random() - 0.5) * 0.1; + const dy = (Math.random() - 0.5) * 0.1; + const magnetism = 0.1 + Math.random() * 4; + return { + x, + y, + translateX, + translateY, + size: pSize, + alpha, + targetAlpha, + dx, + dy, + magnetism, + }; + }; + + const rgb = hexToRgb(color); + + const drawCircle = (circle: Circle, update = false) => { + if (context.current) { + const { x, y, translateX, translateY, size, alpha } = circle; + context.current.translate(translateX, translateY); + context.current.beginPath(); + context.current.arc(x, y, size, 0, 2 * Math.PI); + context.current.fillStyle = `rgba(${rgb.join(", ")}, ${alpha})`; + context.current.fill(); + context.current.setTransform(dpr, 0, 0, dpr, 0, 0); + + if (!update) { + circles.current.push(circle); + } + } + }; + + const clearContext = () => { + if (context.current) { + context.current.clearRect( + 0, + 0, + canvasSize.current.w, + canvasSize.current.h, + ); + } + }; + + const drawParticles = () => { + clearContext(); + const particleCount = quantity; + for (let i = 0; i < particleCount; i++) { + const circle = circleParams(); + drawCircle(circle); + } + }; + + const remapValue = ( + value: number, + start1: number, + end1: number, + start2: number, + end2: number, + ): number => { + const remapped = + ((value - start1) * (end2 - start2)) / (end1 - start1) + start2; + return remapped > 0 ? remapped : 0; + }; + + const animate = () => { + clearContext(); + circles.current.forEach((circle: Circle, i: number) => { + // Handle the alpha value + const edge = [ + circle.x + circle.translateX - circle.size, // distance from left edge + canvasSize.current.w - circle.x - circle.translateX - circle.size, // distance from right edge + circle.y + circle.translateY - circle.size, // distance from top edge + canvasSize.current.h - circle.y - circle.translateY - circle.size, // distance from bottom edge + ]; + const closestEdge = edge.reduce((a, b) => Math.min(a, b)); + const remapClosestEdge = parseFloat( + remapValue(closestEdge, 0, 20, 0, 1).toFixed(2), + ); + if (remapClosestEdge > 1) { + circle.alpha += 0.02; + if (circle.alpha > circle.targetAlpha) { + circle.alpha = circle.targetAlpha; + } + } else { + circle.alpha = circle.targetAlpha * remapClosestEdge; + } + circle.x += circle.dx + vx; + circle.y += circle.dy + vy; + circle.translateX += + (mouse.current.x / (staticity / circle.magnetism) - circle.translateX) / + ease; + circle.translateY += + (mouse.current.y / (staticity / circle.magnetism) - circle.translateY) / + ease; + + drawCircle(circle, true); + + // circle gets out of the canvas + if ( + circle.x < -circle.size || + circle.x > canvasSize.current.w + circle.size || + circle.y < -circle.size || + circle.y > canvasSize.current.h + circle.size + ) { + // remove the circle from the array + circles.current.splice(i, 1); + // create a new circle + const newCircle = circleParams(); + drawCircle(newCircle); + } + }); + rafID.current = window.requestAnimationFrame(animate); + }; + + return ( + + ); +}; diff --git a/dev-share-ui/components/ui/sidebar.tsx b/dev-share-ui/components/ui/sidebar.tsx new file mode 100644 index 0000000..91592e6 --- /dev/null +++ b/dev-share-ui/components/ui/sidebar.tsx @@ -0,0 +1,773 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { VariantProps, cva } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +
diff --git a/dev-share-ui/components/ResourceCardSkeleton.tsx b/dev-share-ui/components/ResourceCardSkeleton.tsx new file mode 100644 index 0000000..0458009 --- /dev/null +++ b/dev-share-ui/components/ResourceCardSkeleton.tsx @@ -0,0 +1,45 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import { Card } from './ui/card'; + +export default function ResourceCardSkeleton() { + return ( + + {/* Top - Avatar and Meta */} +
+ +
+ + +
+
+ + {/* Comment line (tooltip in real) */} +
+ +
+ + {/* Title, Description, Tags */} +
+ + + + + {/* Tags */} +
+ + + +
+
+ + {/* Footer */} +
+
+ + +
+ +
+
+ ); +} diff --git a/dev-share-ui/components/ResourceTable.tsx b/dev-share-ui/components/ResourceTable.tsx new file mode 100644 index 0000000..ada466c --- /dev/null +++ b/dev-share-ui/components/ResourceTable.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { handleGlobalSearch, searchResources } from '@/services/search-service'; +import ResourceCard from './ResourceCard'; +import { mockResources } from '@/lib/data'; + +interface ResourceTableProps { + query: string; +} + +export default async function ResourceTable({ query }: ResourceTableProps) { + const resources = await searchResources(query); + + const handleResourceAction = (id: string, action: 'like' | 'bookmark') => { + // TODO: Integrate with feedback loop API + // Integration point for feedback loop: + // 1. Send user action to your API + // 2. Update resource in your database + // 3. Use this data to improve recommendations + // setResources((prevResources) => + // prevResources.map((resource) => { + // if (resource.id === id) { + // if (action === 'like') { + // return { + // ...resource, + // likes: resource.isLiked ? resource.likes - 1 : resource.likes + 1, + // isLiked: !resource.isLiked, + // }; + // } else { + // return { ...resource, isBookmarked: !resource.isBookmarked }; + // } + // } + // return resource; + // }) + // ); + }; + return ( +
+
+
+ + {`${resources.length} resources found`} + +
+
+ { +
+ {resources + .sort((a, b) => b.likes - a.likes) + .map((resource) => ( + + ))} +
+ } +
+ ); +} diff --git a/dev-share-ui/components/ResourceTableSkeleton.tsx b/dev-share-ui/components/ResourceTableSkeleton.tsx new file mode 100644 index 0000000..72768a5 --- /dev/null +++ b/dev-share-ui/components/ResourceTableSkeleton.tsx @@ -0,0 +1,20 @@ +import ResourceCardSkeleton from './ResourceCardSkeleton'; + +export default function ResourceTableSkeleton() { + return ( +
+
+ + Loading resources... + +
+
+ {Array(6) + .fill(1) + .map((index) => ( + + ))} +
+
+ ); +} diff --git a/dev-share-ui/components/SearchBar.tsx b/dev-share-ui/components/SearchBar.tsx new file mode 100644 index 0000000..b5e8353 --- /dev/null +++ b/dev-share-ui/components/SearchBar.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Search } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { useSearchParams, usePathname, useRouter } from 'next/navigation'; +import { useDebouncedCallback } from 'use-debounce'; + +export default function SearchBar() { + const router = useRouter(); + const [query, setQuery] = useState(''); + const [placeholderIndex, setPlaceholderIndex] = useState(0); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const placeholders = [ + 'Search resources, tools, guides...', + 'TypeScript tutorials for beginners', + 'How to optimize Next.js performance', + 'GraphQL resources for frontend developers', + 'Learn CSS Grid and Flexbox', + 'Node.js backend architecture patterns', + ]; + + // Rotate placeholder text every 5 seconds + useEffect(() => { + const interval = setInterval(() => { + setPlaceholderIndex((prev) => (prev + 1) % placeholders.length); + }, 5000); + + return () => clearInterval(interval); + }, [placeholders.length]); + + const handleSearch = useDebouncedCallback((term) => { + const params = new URLSearchParams(Array.from(searchParams.entries())); + if (term) { + params.set('query', term); + } else { + params.delete('query'); + } + + router.replace(`${pathname}?${params.toString()}`); + }, 400); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (query.trim()) { + router.push(`/result/?query=${query}`); + } + }; + return ( +
+ + { + handleSearch(e.target.value); + setQuery(e.target.value); + }} + defaultValue={searchParams.get('query')?.toString()} + className="pl-10 h-11 rounded-lg bg-card transition-all duration-300 focus:ring-2 focus:ring-primary/20 w-3/5" + /> + + ); +} diff --git a/dev-share-ui/package-lock.json b/dev-share-ui/package-lock.json index cd47866..5a3209f 100644 --- a/dev-share-ui/package-lock.json +++ b/dev-share-ui/package-lock.json @@ -64,6 +64,7 @@ "tailwindcss": "3.3.3", "tailwindcss-animate": "^1.0.7", "typescript": "5.2.2", + "use-debounce": "^10.0.5", "vaul": "^0.9.9", "zod": "^3.23.8" } @@ -6825,6 +6826,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.5.tgz", + "integrity": "sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-sidecar": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.2.tgz", diff --git a/dev-share-ui/package.json b/dev-share-ui/package.json index a0806d1..67fefcd 100644 --- a/dev-share-ui/package.json +++ b/dev-share-ui/package.json @@ -65,6 +65,7 @@ "tailwindcss": "3.3.3", "tailwindcss-animate": "^1.0.7", "typescript": "5.2.2", + "use-debounce": "^10.0.5", "vaul": "^0.9.9", "zod": "^3.23.8" } From 815f8bd9bd1c0ea26ac553c448cee83667af2069 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 17 Jul 2025 22:00:35 +1000 Subject: [PATCH 03/14] fix first load does not update search query --- dev-share-ui/components/SearchBar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dev-share-ui/components/SearchBar.tsx b/dev-share-ui/components/SearchBar.tsx index b5e8353..153d520 100644 --- a/dev-share-ui/components/SearchBar.tsx +++ b/dev-share-ui/components/SearchBar.tsx @@ -29,7 +29,10 @@ export default function SearchBar() { return () => clearInterval(interval); }, [placeholders.length]); - + useEffect(() => { + const initialQuery = searchParams.get('query') || ''; + setQuery(initialQuery); + }, [searchParams]); const handleSearch = useDebouncedCallback((term) => { const params = new URLSearchParams(Array.from(searchParams.entries())); if (term) { @@ -61,6 +64,7 @@ export default function SearchBar() { setQuery(e.target.value); }} defaultValue={searchParams.get('query')?.toString()} + value={query} className="pl-10 h-11 rounded-lg bg-card transition-all duration-300 focus:ring-2 focus:ring-primary/20 w-3/5" /> From e8d0281a54ea9bb5e7b7a61bad0fd5c9356ecbea Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 17 Jul 2025 22:09:41 +1000 Subject: [PATCH 04/14] remove side bar package --- dev-share-ui/components/ui/sidebar.tsx | 773 ------------------------- dev-share-ui/package-lock.json | 33 -- dev-share-ui/package.json | 1 - 3 files changed, 807 deletions(-) delete mode 100644 dev-share-ui/components/ui/sidebar.tsx diff --git a/dev-share-ui/components/ui/sidebar.tsx b/dev-share-ui/components/ui/sidebar.tsx deleted file mode 100644 index 91592e6..0000000 --- a/dev-share-ui/components/ui/sidebar.tsx +++ /dev/null @@ -1,773 +0,0 @@ -"use client" - -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { VariantProps, cva } from "class-variance-authority" -import { PanelLeft } from "lucide-react" - -import { useIsMobile } from "@/hooks/use-mobile" -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { Skeleton } from "@/components/ui/skeleton" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" - -const SIDEBAR_COOKIE_NAME = "sidebar_state" -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = "16rem" -const SIDEBAR_WIDTH_MOBILE = "18rem" -const SIDEBAR_WIDTH_ICON = "3rem" -const SIDEBAR_KEYBOARD_SHORTCUT = "b" - -type SidebarContextProps = { - state: "expanded" | "collapsed" - open: boolean - setOpen: (open: boolean) => void - openMobile: boolean - setOpenMobile: (open: boolean) => void - isMobile: boolean - toggleSidebar: () => void -} - -const SidebarContext = React.createContext(null) - -function useSidebar() { - const context = React.useContext(SidebarContext) - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider.") - } - - return context -} - -const SidebarProvider = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - defaultOpen?: boolean - open?: boolean - onOpenChange?: (open: boolean) => void - } ->( - ( - { - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props - }, - ref - ) => { - const isMobile = useIsMobile() - const [openMobile, setOpenMobile] = React.useState(false) - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen) - const open = openProp ?? _open - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value - if (setOpenProp) { - setOpenProp(openState) - } else { - _setOpen(openState) - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` - }, - [setOpenProp, open] - ) - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile - ? setOpenMobile((open) => !open) - : setOpen((open) => !open) - }, [isMobile, setOpen, setOpenMobile]) - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault() - toggleSidebar() - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [toggleSidebar]) - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed" - - const contextValue = React.useMemo( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] - ) - - return ( - - -
- {children} -
-
-
- ) - } -) -SidebarProvider.displayName = "SidebarProvider" - -const Sidebar = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - side?: "left" | "right" - variant?: "sidebar" | "floating" | "inset" - collapsible?: "offcanvas" | "icon" | "none" - } ->( - ( - { - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props - }, - ref - ) => { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() - - if (collapsible === "none") { - return ( -
- {children} -
- ) - } - - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - -
{children}
-
-
- ) - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */} -
- -
- ) - } -) -Sidebar.displayName = "Sidebar" - -const SidebarTrigger = React.forwardRef< - React.ElementRef, - React.ComponentProps ->(({ className, onClick, ...props }, ref) => { - const { toggleSidebar } = useSidebar() - - return ( - - ) -}) -SidebarTrigger.displayName = "SidebarTrigger" - -const SidebarRail = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> ->(({ className, ...props }, ref) => { - const { toggleSidebar } = useSidebar() - - return ( -