a new start

This commit is contained in:
valknarness
2025-10-25 16:09:02 +02:00
commit b63592f153
94 changed files with 23058 additions and 0 deletions

View File

@@ -0,0 +1,338 @@
'use client'
import * as React from 'react'
import {
Download,
Upload,
FileText,
Eye,
Code,
LayoutGrid,
Trash2,
Save,
Copy,
Check,
} from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
EditorProvider,
EditorBubbleMenu,
EditorFormatBold,
EditorFormatItalic,
EditorFormatStrike,
EditorFormatCode,
EditorNodeHeading1,
EditorNodeHeading2,
EditorNodeHeading3,
EditorNodeBulletList,
EditorNodeOrderedList,
EditorNodeTaskList,
EditorNodeQuote,
EditorNodeCode,
EditorLinkSelector,
EditorClearFormatting,
type JSONContent,
} from '@/components/ui/shadcn-io/editor'
import { usePersonalListStore } from '@/lib/personal-list-store'
import { cn } from '@/lib/utils'
import { PersonalListItems } from './personal-list-items'
export function PersonalListEditor() {
const {
markdown,
setMarkdown,
activeView,
setActiveView,
items,
generateMarkdown,
exportList,
importList,
clearList,
} = usePersonalListStore()
const [content, setContent] = React.useState<JSONContent | string>(markdown)
const [copied, setCopied] = React.useState(false)
const fileInputRef = React.useRef<HTMLInputElement>(null)
// Update content when markdown changes
React.useEffect(() => {
setContent(markdown)
}, [markdown])
const handleEditorUpdate = React.useCallback(
({ editor }: { editor: any }) => {
const md = editor.storage.markdown?.getMarkdown() || editor.getText()
setMarkdown(md)
},
[setMarkdown]
)
const handleExportMarkdown = () => {
const md = generateMarkdown()
const blob = new Blob([md], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `my-awesome-list-${Date.now()}.md`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('Markdown exported successfully!')
}
const handleExportJSON = () => {
const data = exportList()
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: 'application/json',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `my-awesome-list-${Date.now()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success('List exported successfully!')
}
const handleImportJSON = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = JSON.parse(e.target?.result as string)
importList(data)
toast.success('List imported successfully!')
} catch (error) {
toast.error('Failed to import list. Invalid JSON format.')
}
}
reader.readAsText(file)
}
const handleCopyMarkdown = async () => {
const md = generateMarkdown()
await navigator.clipboard.writeText(md)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
toast.success('Markdown copied to clipboard!')
}
const handleClear = () => {
if (confirm('Are you sure you want to clear your entire list? This cannot be undone.')) {
clearList()
toast.success('List cleared successfully!')
}
}
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-4 py-2">
<TooltipProvider>
<div className="flex items-center gap-1">
{/* View Mode Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={activeView === 'editor' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
onClick={() => setActiveView('editor')}
>
<Code className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Editor Mode</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={activeView === 'split' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
onClick={() => setActiveView('split')}
>
<LayoutGrid className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split View</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={activeView === 'preview' ? 'default' : 'ghost'}
size="sm"
className="h-8 w-8 p-0"
onClick={() => setActiveView('preview')}
>
<Eye className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Preview Mode</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="mx-2 h-6" />
{/* Actions */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleCopyMarkdown}
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Copy Markdown</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleExportMarkdown}
>
<FileText className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Export Markdown</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={handleExportJSON}
>
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Export JSON</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Import JSON</TooltipContent>
</Tooltip>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImportJSON}
/>
<Separator orientation="vertical" className="mx-2 h-6" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleClear}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear List</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
<div className="text-muted-foreground text-xs">
{items.length} {items.length === 1 ? 'item' : 'items'}
</div>
</div>
{/* Content */}
<div className="flex flex-1 overflow-hidden">
{/* Editor View */}
{(activeView === 'editor' || activeView === 'split') && (
<div
className={cn(
'flex-1 overflow-auto border-r border-border p-4',
activeView === 'split' && 'w-1/2'
)}
>
<EditorProvider
content={content}
onUpdate={handleEditorUpdate}
placeholder="Start writing your awesome list in markdown..."
className="prose prose-sm dark:prose-invert max-w-none"
immediatelyRender={false}
>
<EditorBubbleMenu>
<EditorFormatBold hideName />
<EditorFormatItalic hideName />
<EditorFormatStrike hideName />
<EditorFormatCode hideName />
<Separator orientation="vertical" />
<EditorNodeHeading1 hideName />
<EditorNodeHeading2 hideName />
<EditorNodeHeading3 hideName />
<Separator orientation="vertical" />
<EditorNodeBulletList hideName />
<EditorNodeOrderedList hideName />
<EditorNodeTaskList hideName />
<Separator orientation="vertical" />
<EditorNodeQuote hideName />
<EditorNodeCode hideName />
<EditorLinkSelector />
<Separator orientation="vertical" />
<EditorClearFormatting hideName />
</EditorBubbleMenu>
</EditorProvider>
</div>
)}
{/* Preview/Items View */}
{(activeView === 'preview' || activeView === 'split') && (
<div
className={cn(
'flex-1 overflow-auto bg-muted/10 p-4',
activeView === 'split' && 'w-1/2'
)}
>
<PersonalListItems />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import * as React from 'react'
import { Trash2, ExternalLink, Calendar, Tag, Folder } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { usePersonalListStore } from '@/lib/personal-list-store'
import { cn } from '@/lib/utils'
export function PersonalListItems() {
const { items, removeItem } = usePersonalListStore()
if (items.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Folder className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="mb-2 font-semibold text-lg">No items yet</h3>
<p className="text-muted-foreground text-sm">
Start building your awesome list by clicking
<br />
<span className="gradient-text font-semibold">&quot;Push to my list&quot;</span>{' '}
on any repository
</p>
</div>
</div>
)
}
// Group items by category
const categorized = items.reduce((acc, item) => {
const category = item.category || 'Uncategorized'
if (!acc[category]) {
acc[category] = []
}
acc[category].push(item)
return acc
}, {} as Record<string, typeof items>)
return (
<div className="space-y-6">
{Object.entries(categorized).map(([category, categoryItems]) => (
<div key={category} className="space-y-3">
<h3 className="flex items-center gap-2 font-semibold text-lg">
<Folder className="h-5 w-5 text-primary" />
<span className="gradient-text">{category}</span>
<Badge variant="secondary" className="ml-auto">
{categoryItems.length}
</Badge>
</h3>
<div className="space-y-3">
{categoryItems.map((item) => (
<Card
key={item.id}
className="group card-awesome border-l-4 transition-all hover:shadow-lg"
style={{
borderLeftColor: 'var(--color-primary)',
}}
>
<CardHeader className="pb-3">
<CardTitle className="flex items-start justify-between gap-2">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-foreground transition-colors hover:text-primary"
>
<span className="line-clamp-1">{item.title}</span>
<ExternalLink className="h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" />
</a>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() => removeItem(item.id)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove item</span>
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm">{item.description}</p>
{item.repository && (
<div className="flex items-center gap-2 text-xs">
<Badge variant="outline" className="font-mono">
{item.repository}
</Badge>
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
<span>
{new Date(item.addedAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</span>
</div>
{item.tags && item.tags.length > 0 && (
<>
<span className="text-muted-foreground/50"></span>
<div className="flex items-center gap-1.5">
<Tag className="h-3 w-3" />
{item.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="h-5 px-1.5 text-xs"
>
{tag}
</Badge>
))}
</div>
</>
)}
</div>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,268 @@
'use client'
import * as React from 'react'
import { Plus, Check } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { AwesomeIcon } from '@/components/ui/awesome-icon'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { usePersonalListStore, type PersonalListItem } from '@/lib/personal-list-store'
import { cn } from '@/lib/utils'
interface PushToListButtonProps {
title: string
description: string
url: string
repository?: string
variant?: 'default' | 'ghost' | 'outline'
size?: 'default' | 'sm' | 'lg' | 'icon'
className?: string
showLabel?: boolean
onPush?: () => void
}
const DEFAULT_CATEGORIES = [
'Development',
'Design',
'Tools',
'Resources',
'Libraries',
'Frameworks',
'Documentation',
'Learning',
'Inspiration',
'Other',
]
export function PushToListButton({
title: initialTitle,
description: initialDescription,
url,
repository,
variant = 'outline',
size = 'default',
className,
showLabel = true,
onPush,
}: PushToListButtonProps) {
const { addItem, openEditor, items } = usePersonalListStore()
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const [isAdded, setIsAdded] = React.useState(false)
const [formData, setFormData] = React.useState({
title: initialTitle,
description: initialDescription,
url,
repository: repository || '',
category: 'Development',
tags: '',
})
// Check if item is already added
React.useEffect(() => {
const alreadyAdded = items.some((item) => item.url === url)
setIsAdded(alreadyAdded)
}, [items, url])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const tags = formData.tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
addItem({
title: formData.title,
description: formData.description,
url: formData.url,
repository: formData.repository || undefined,
category: formData.category,
tags: tags.length > 0 ? tags : undefined,
})
setIsDialogOpen(false)
setIsAdded(true)
toast.success(
<div className="flex items-center gap-2">
<AwesomeIcon size={16} />
<span>Added to your awesome list!</span>
</div>,
{
action: {
label: 'View List',
onClick: () => openEditor(),
},
}
)
onPush?.()
}
return (
<>
<Button
variant={isAdded ? 'default' : variant}
size={size}
className={cn(
'group transition-all',
isAdded && 'btn-awesome cursor-default',
className
)}
onClick={() => !isAdded && setIsDialogOpen(true)}
disabled={isAdded}
>
{isAdded ? (
<>
<Check className="h-4 w-4" />
{showLabel && <span className="ml-2">Added</span>}
</>
) : (
<>
<Plus className="h-4 w-4 transition-transform group-hover:rotate-90" />
{showLabel && <span className="ml-2">Push to my list</span>}
</>
)}
</Button>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AwesomeIcon size={20} />
<span className="gradient-text">Add to My Awesome List</span>
</DialogTitle>
<DialogDescription>
Customize the details before adding this item to your personal list.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={formData.title}
onChange={(e) =>
setFormData({ ...formData, title: e.target.value })
}
required
placeholder="Enter title"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
required
placeholder="Enter description"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="url">URL *</Label>
<Input
id="url"
type="url"
value={formData.url}
onChange={(e) =>
setFormData({ ...formData, url: e.target.value })
}
required
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<Label htmlFor="repository">Repository (optional)</Label>
<Input
id="repository"
value={formData.repository}
onChange={(e) =>
setFormData({ ...formData, repository: e.target.value })
}
placeholder="owner/repo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category *</Label>
<Select
value={formData.category || 'Development'}
onValueChange={(value) =>
setFormData({ ...formData, category: value || 'Development' })
}
>
<SelectTrigger id="category">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{DEFAULT_CATEGORIES.filter(cat => cat && cat.trim()).map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="tags">Tags (optional)</Label>
<Input
id="tags"
value={formData.tags}
onChange={(e) =>
setFormData({ ...formData, tags: e.target.value })
}
placeholder="react, typescript, ui (comma separated)"
/>
<p className="text-muted-foreground text-xs">
Separate tags with commas
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" className="btn-awesome">
<Plus className="mr-2 h-4 w-4" />
Add to List
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,209 @@
'use client'
import * as React from 'react'
import { motion, useMotionValue, useSpring, useTransform } from 'motion/react'
import { GripVerticalIcon, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
interface SlidingPanelContextType {
panelWidth: number
setPanelWidth: (width: number) => void
motionPanelWidth: ReturnType<typeof useMotionValue<number>>
isPanelOpen: boolean
closePanel: () => void
}
const SlidingPanelContext = React.createContext<SlidingPanelContextType | undefined>(undefined)
const useSlidingPanel = () => {
const context = React.useContext(SlidingPanelContext)
if (!context) {
throw new Error('useSlidingPanel must be used within a SlidingPanel')
}
return context
}
export interface SlidingPanelProps {
children: React.ReactNode
isOpen: boolean
onClose: () => void
defaultWidth?: number
minWidth?: number
maxWidth?: number
className?: string
}
export function SlidingPanel({
children,
isOpen,
onClose,
defaultWidth = 50,
minWidth = 30,
maxWidth = 70,
className,
}: SlidingPanelProps) {
const [isDragging, setIsDragging] = React.useState(false)
const motionValue = useMotionValue(defaultWidth)
const motionPanelWidth = useSpring(motionValue, {
bounce: 0,
duration: isDragging ? 0 : 300,
})
const [panelWidth, setPanelWidth] = React.useState(defaultWidth)
// Calculate resizer position - must be called unconditionally
const resizerLeft = useTransform(motionPanelWidth, (value) => `${value}%`)
const handleDrag = (domRect: DOMRect, clientX: number) => {
if (!isDragging) return
const x = clientX - domRect.left
const percentage = Math.min(
Math.max((x / domRect.width) * 100, minWidth),
maxWidth
)
motionValue.set(percentage)
setPanelWidth(percentage)
}
const handleMouseDrag = (event: React.MouseEvent<HTMLDivElement>) => {
if (!event.currentTarget) return
const containerRect = event.currentTarget.getBoundingClientRect()
handleDrag(containerRect, event.clientX)
}
const handleTouchDrag = (event: React.TouchEvent<HTMLDivElement>) => {
if (!event.currentTarget) return
const containerRect = event.currentTarget.getBoundingClientRect()
const touch = event.touches[0]
if (touch) {
handleDrag(containerRect, touch.clientX)
}
}
return (
<SlidingPanelContext.Provider
value={{
panelWidth,
setPanelWidth,
motionPanelWidth,
isPanelOpen: isOpen,
closePanel: onClose,
}}
>
<div
className={cn(
'relative w-full',
isDragging && 'select-none',
className
)}
onMouseMove={handleMouseDrag}
onMouseUp={() => setIsDragging(false)}
onMouseLeave={() => setIsDragging(false)}
onTouchMove={handleTouchDrag}
onTouchEnd={() => setIsDragging(false)}
>
{children}
{/* Resizer Handle */}
{isOpen && (
<motion.div
className={cn(
'absolute top-0 z-50 flex h-full w-1 items-center justify-center bg-primary/10 transition-colors hover:bg-primary/30',
isDragging && 'bg-primary/40'
)}
style={{
left: resizerLeft,
}}
>
<motion.div
className={cn(
'absolute flex h-16 w-6 cursor-col-resize items-center justify-center rounded-md border-2 border-primary/20 bg-background shadow-lg transition-all hover:border-primary/60 hover:shadow-xl',
isDragging && 'border-primary/60 shadow-xl'
)}
onMouseDown={() => setIsDragging(true)}
onTouchStart={() => setIsDragging(true)}
>
<GripVerticalIcon className="h-4 w-4 text-muted-foreground" />
</motion.div>
</motion.div>
)}
</div>
</SlidingPanelContext.Provider>
)
}
export interface SlidingPanelContentProps {
children: React.ReactNode
className?: string
}
export function SlidingPanelMain({ children, className }: SlidingPanelContentProps) {
const { motionPanelWidth, isPanelOpen } = useSlidingPanel()
const width = useTransform(
motionPanelWidth,
(value: number) => isPanelOpen ? `${value}%` : '100%'
)
return (
<motion.div
className={cn('h-full overflow-auto', className)}
style={{ width }}
>
{children}
</motion.div>
)
}
export interface SlidingPanelSideProps {
children: React.ReactNode
className?: string
title?: string
}
export function SlidingPanelSide({ children, className, title }: SlidingPanelSideProps) {
const { motionPanelWidth, isPanelOpen, closePanel } = useSlidingPanel()
const width = useTransform(
motionPanelWidth,
(value: number) => `${100 - value}%`
)
if (!isPanelOpen) return null
return (
<motion.div
className={cn(
'absolute right-0 top-0 h-full border-l border-border bg-background',
className
)}
style={{ width }}
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-4 py-3 backdrop-blur-sm">
<h2 className="gradient-text text-lg font-semibold">
{title || 'My Awesome List'}
</h2>
<Button
variant="ghost"
size="icon"
onClick={closePanel}
className="h-8 w-8 rounded-full"
>
<X className="h-4 w-4" />
<span className="sr-only">Close panel</span>
</Button>
</div>
{/* Content */}
<div className="h-[calc(100%-57px)] overflow-auto">
{children}
</div>
</motion.div>
)
}