a new start
This commit is contained in:
338
components/personal-list/personal-list-editor.tsx
Normal file
338
components/personal-list/personal-list-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
components/personal-list/personal-list-items.tsx
Normal file
136
components/personal-list/personal-list-items.tsx
Normal 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">"Push to my list"</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>
|
||||
)
|
||||
}
|
||||
268
components/personal-list/push-to-list-button.tsx
Normal file
268
components/personal-list/push-to-list-button.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
209
components/personal-list/sliding-panel.tsx
Normal file
209
components/personal-list/sliding-panel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user