* chore: run Tailwind v4 upgrade tool (Phase 1) - Upgraded tailwindcss v3 → v4.2.1, postcss plugin to @tailwindcss/postcss - Deleted tailwind.config.js, migrated theme to CSS @theme block in index.css - Replaced @tailwind directives with @import 'tailwindcss' - Added @custom-variant dark, @utility blocks for custom utilities - Updated class names across 128 files (shadow-sm → shadow-xs, etc.) - Removed autoprefixer (built into v4) - Added migration plan doc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: switch from @tailwindcss/postcss to @tailwindcss/vite (Phase 2) - Replaced @tailwindcss/postcss with @tailwindcss/vite plugin - Deleted postcss.config.js (no longer needed) - Tailwind now runs as a native Vite plugin for faster HMR Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: convert to OKLCH colors, move keyframes into @theme (Phase 3-4) - Replaced all HSL color indirection with direct OKLCH values in @theme - Moved all keyframes inside @theme block (v4 pattern) - Eliminated hsl(var(--x)) double-indirection across 17 component files - Replaced hsl() inline styles with var(--color-*) theme references - Cleaned up redundant rdp-* utility blocks - Fixed @custom-variant dark syntax to use :where() - Added sidebar/glass/shadow vars as OKLCH in :root Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
280 lines
9.6 KiB
TypeScript
280 lines
9.6 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { X, Copy, Check, Link2, Users, Lock, Globe } from 'lucide-react'
|
|
import type { TreeListItem, TreeShare, TreeVisibility } from '@/types'
|
|
import { treesApi } from '@/api/trees'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
|
|
interface ShareTreeModalProps {
|
|
tree: TreeListItem
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
|
const [isGenerating, setIsGenerating] = useState(false)
|
|
const [shares, setShares] = useState<TreeShare[]>([])
|
|
const [activeShare, setActiveShare] = useState<TreeShare | null>(null)
|
|
const [copied, setCopied] = useState(false)
|
|
const [allowForking, setAllowForking] = useState(true)
|
|
const [visibility, setVisibility] = useState<TreeVisibility>('private')
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadShares()
|
|
// Reset state
|
|
setCopied(false)
|
|
setAllowForking(true)
|
|
}
|
|
}, [isOpen, tree.id])
|
|
|
|
const loadShares = async () => {
|
|
try {
|
|
const sharesData = await treesApi.listShares(tree.id)
|
|
setShares(sharesData)
|
|
// Set active share to most recent
|
|
if (sharesData.length > 0) {
|
|
setActiveShare(sharesData[0])
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load shares:', err)
|
|
}
|
|
}
|
|
|
|
const handleGenerateLink = async () => {
|
|
setIsGenerating(true)
|
|
try {
|
|
const newShare = await treesApi.createShare(tree.id, {
|
|
allow_forking: allowForking,
|
|
})
|
|
setShares([newShare, ...shares])
|
|
setActiveShare(newShare)
|
|
toast.success('Share link generated')
|
|
} catch (err) {
|
|
console.error('Failed to generate share link:', err)
|
|
toast.error('Failed to generate share link')
|
|
} finally {
|
|
setIsGenerating(false)
|
|
}
|
|
}
|
|
|
|
const handleCopyLink = async () => {
|
|
if (!activeShare) return
|
|
try {
|
|
await navigator.clipboard.writeText(activeShare.share_url)
|
|
setCopied(true)
|
|
toast.success('Link copied to clipboard')
|
|
setTimeout(() => setCopied(false), 2000)
|
|
} catch (err) {
|
|
console.error('Failed to copy link:', err)
|
|
toast.error('Failed to copy link')
|
|
}
|
|
}
|
|
|
|
const handleVisibilityChange = async (newVisibility: TreeVisibility) => {
|
|
try {
|
|
await treesApi.updateVisibility(tree.id, { visibility: newVisibility })
|
|
setVisibility(newVisibility)
|
|
toast.success('Visibility updated')
|
|
} catch (err) {
|
|
console.error('Failed to update visibility:', err)
|
|
toast.error('Failed to update visibility')
|
|
}
|
|
}
|
|
|
|
const getVisibilityIcon = (level: TreeVisibility) => {
|
|
switch (level) {
|
|
case 'private':
|
|
return <Lock className="h-4 w-4" />
|
|
case 'team':
|
|
return <Users className="h-4 w-4" />
|
|
case 'link':
|
|
return <Link2 className="h-4 w-4" />
|
|
case 'public':
|
|
return <Globe className="h-4 w-4" />
|
|
}
|
|
}
|
|
|
|
const getVisibilityDescription = (level: TreeVisibility) => {
|
|
switch (level) {
|
|
case 'private':
|
|
return 'Only you can access'
|
|
case 'team':
|
|
return 'Team members can access'
|
|
case 'link':
|
|
return 'Anyone with the link'
|
|
case 'public':
|
|
return 'Discoverable by everyone'
|
|
}
|
|
}
|
|
|
|
if (!isOpen) return null
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-black/80 backdrop-blur-xs"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Modal */}
|
|
<div className="relative w-full max-w-lg bg-card border border-border rounded-2xl shadow-lg">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
|
<h2 className="text-lg font-semibold text-foreground">Share Tree</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded-md p-1 text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="px-6 py-4 space-y-6">
|
|
{/* Tree Info */}
|
|
<div>
|
|
<h3 className="font-medium text-foreground">{tree.name}</h3>
|
|
{tree.description && (
|
|
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
|
{tree.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Visibility Settings */}
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
Visibility
|
|
</label>
|
|
<div className="space-y-2">
|
|
{(['private', 'team', 'link', 'public'] as TreeVisibility[]).map((level) => (
|
|
<button
|
|
key={level}
|
|
onClick={() => handleVisibilityChange(level)}
|
|
className={cn(
|
|
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
|
visibility === level
|
|
? 'border-border bg-accent text-foreground'
|
|
: 'border-border bg-transparent text-muted-foreground hover:border-primary/30 hover:bg-accent/50'
|
|
)}
|
|
>
|
|
{getVisibilityIcon(level)}
|
|
<div className="flex-1">
|
|
<div className="text-sm font-medium capitalize">{level}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{getVisibilityDescription(level)}
|
|
</div>
|
|
</div>
|
|
{visibility === level && (
|
|
<div className="h-2 w-2 rounded-full bg-foreground" />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Share Link Generation */}
|
|
{visibility !== 'private' && (
|
|
<div>
|
|
<label className="mb-2 block text-sm font-medium text-foreground">
|
|
Share Link
|
|
</label>
|
|
|
|
{/* Allow Forking Checkbox */}
|
|
<div className="mb-3 flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="allow-forking"
|
|
checked={allowForking}
|
|
onChange={(e) => setAllowForking(e.target.checked)}
|
|
className="h-4 w-4 rounded border-border bg-card text-foreground focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 focus:ring-offset-black"
|
|
/>
|
|
<label
|
|
htmlFor="allow-forking"
|
|
className="text-sm text-muted-foreground cursor-pointer"
|
|
>
|
|
Allow recipients to fork this tree
|
|
</label>
|
|
</div>
|
|
|
|
{/* Generate Button */}
|
|
{!activeShare && (
|
|
<button
|
|
onClick={handleGenerateLink}
|
|
disabled={isGenerating}
|
|
className={cn(
|
|
'w-full rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
|
|
)}
|
|
>
|
|
{isGenerating ? 'Generating...' : 'Generate Share Link'}
|
|
</button>
|
|
)}
|
|
|
|
{/* Active Share Link */}
|
|
{activeShare && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 rounded-md border border-border bg-card p-3">
|
|
<input
|
|
type="text"
|
|
value={activeShare.share_url}
|
|
readOnly
|
|
className="flex-1 bg-transparent text-sm text-foreground outline-hidden"
|
|
/>
|
|
<button
|
|
onClick={handleCopyLink}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors',
|
|
copied
|
|
? 'border-green-500 bg-green-500/10 text-green-400'
|
|
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
{copied ? (
|
|
<>
|
|
<Check className="h-4 w-4" />
|
|
Copied
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy className="h-4 w-4" />
|
|
Copy
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{activeShare.allow_forking
|
|
? 'Recipients can fork this tree'
|
|
: 'Forking disabled for this share'}
|
|
</p>
|
|
{shares.length > 1 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{shares.length} active share links
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex justify-end gap-3 border-t border-border px-6 py-4">
|
|
<button
|
|
onClick={onClose}
|
|
className={cn(
|
|
'rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|