From b102e86b33344e809b9005353589880d9643e2b0 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Tue, 2 Dec 2025 01:02:33 +0000 Subject: [PATCH] feat: create sharing UI components (task 13) - Add PermissionBadge component to display owner/editor/viewer roles - Add InvitationCard component for accepting/declining invitations - Add CollaboratorList component to manage list collaborators - Add ShareListModal component for inviting users and viewing access - Add invitations page to view and manage pending invitations Requirements: 5.1, 5.4, 8.1, 8.2 --- app/invitations/page.tsx | 100 +++++++++++++++++++ components/CollaboratorList.tsx | 155 +++++++++++++++++++++++++++++ components/InvitationCard.tsx | 124 +++++++++++++++++++++++ components/PermissionBadge.tsx | 43 ++++++++ components/ShareListModal.tsx | 171 ++++++++++++++++++++++++++++++++ 5 files changed, 593 insertions(+) create mode 100644 app/invitations/page.tsx create mode 100644 components/CollaboratorList.tsx create mode 100644 components/InvitationCard.tsx create mode 100644 components/PermissionBadge.tsx create mode 100644 components/ShareListModal.tsx diff --git a/app/invitations/page.tsx b/app/invitations/page.tsx new file mode 100644 index 0000000..7a60146 --- /dev/null +++ b/app/invitations/page.tsx @@ -0,0 +1,100 @@ +import { auth } from '@/auth' +import { redirect } from 'next/navigation' +import { getInvitations } from '@/lib/sharing-actions' +import InvitationCard from '@/components/InvitationCard' +import { Mail } from 'lucide-react' +import Link from 'next/link' + +type Invitation = { + id: string + email: string + permission: 'editor' | 'viewer' + status: string + createdAt: Date + expiresAt: Date + listId: string + inviterId: string + list: { + id: string + title: string + color: string + } + inviter: { + id: string + name: string | null + email: string + } +} + +export default async function InvitationsPage() { + const session = await auth() + + if (!session?.user) { + redirect('/auth/login') + } + + const invitations = await getInvitations() as Invitation[] + + return ( +
+
+ {/* Header */} +
+ + ← Back to Lists + +
+
+ +
+
+

+ Invitations +

+

+ {invitations.length === 0 + ? 'No pending invitations' + : `${invitations.length} pending ${invitations.length === 1 ? 'invitation' : 'invitations'}`} +

+
+
+
+ + {/* Invitations List */} + {invitations.length === 0 ? ( +
+
+
+ +
+

+ No Invitations +

+

+ When someone shares a list with you, you'll see the invitation here. +

+ + Go to My Lists + +
+
+ ) : ( +
+ {invitations.map((invitation) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/components/CollaboratorList.tsx b/components/CollaboratorList.tsx new file mode 100644 index 0000000..6db0836 --- /dev/null +++ b/components/CollaboratorList.tsx @@ -0,0 +1,155 @@ +'use client' + +import { useState } from 'react' +import { UserMinus, ChevronDown } from 'lucide-react' +import PermissionBadge from './PermissionBadge' +import { updateCollaboratorPermission, removeCollaborator } from '@/lib/sharing-actions' +import { useRouter } from 'next/navigation' + +interface Collaborator { + id: string + permission: 'editor' | 'viewer' + user: { + id: string + name: string | null + email: string + } +} + +interface CollaboratorListProps { + listId: string + collaborators: Collaborator[] + isOwner: boolean + ownerId: string + ownerName: string | null + ownerEmail: string +} + +export default function CollaboratorList({ + listId, + collaborators, + isOwner, + ownerId, + ownerName, + ownerEmail, +}: CollaboratorListProps) { + const [loadingStates, setLoadingStates] = useState>({}) + const [error, setError] = useState(null) + const router = useRouter() + + const handlePermissionChange = async (collaboratorId: string, newPermission: 'editor' | 'viewer') => { + setLoadingStates(prev => ({ ...prev, [collaboratorId]: true })) + setError(null) + + try { + await updateCollaboratorPermission(listId, collaboratorId, newPermission) + router.refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update permission') + } finally { + setLoadingStates(prev => ({ ...prev, [collaboratorId]: false })) + } + } + + const handleRemove = async (collaboratorId: string) => { + if (!confirm('Are you sure you want to remove this collaborator?')) { + return + } + + setLoadingStates(prev => ({ ...prev, [collaboratorId]: true })) + setError(null) + + try { + await removeCollaborator(listId, collaboratorId) + router.refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove collaborator') + setLoadingStates(prev => ({ ...prev, [collaboratorId]: false })) + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {/* Owner */} +
+
+

+ {ownerName || ownerEmail} +

+ {ownerName && ( +

{ownerEmail}

+ )} +
+ +
+ + {/* Collaborators */} + {collaborators.length === 0 ? ( +

+ No collaborators yet. Share this list to collaborate! +

+ ) : ( + collaborators.map((collaborator) => ( +
+
+

+ {collaborator.user.name || collaborator.user.email} +

+ {collaborator.user.name && ( +

+ {collaborator.user.email} +

+ )} +
+ +
+ {isOwner ? ( + <> +
+ + +
+ + + ) : ( + + )} +
+
+ )) + )} +
+ ) +} diff --git a/components/InvitationCard.tsx b/components/InvitationCard.tsx new file mode 100644 index 0000000..6eacb07 --- /dev/null +++ b/components/InvitationCard.tsx @@ -0,0 +1,124 @@ +'use client' + +import { useState } from 'react' +import { Check, X, Clock } from 'lucide-react' +import PermissionBadge from './PermissionBadge' +import { acceptInvitation, declineInvitation } from '@/lib/sharing-actions' +import { useRouter } from 'next/navigation' + +interface InvitationCardProps { + invitation: { + id: string + permission: 'editor' | 'viewer' + createdAt: Date + list: { + id: string + title: string + color: string + } + inviter: { + name: string | null + email: string + } + } + onUpdate?: () => void +} + +export default function InvitationCard({ invitation, onUpdate }: InvitationCardProps) { + const [isAccepting, setIsAccepting] = useState(false) + const [isDeclining, setIsDeclining] = useState(false) + const [error, setError] = useState(null) + const router = useRouter() + + const handleAccept = async () => { + setIsAccepting(true) + setError(null) + + try { + await acceptInvitation(invitation.id) + onUpdate?.() + router.refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to accept invitation') + setIsAccepting(false) + } + } + + const handleDecline = async () => { + setIsDeclining(true) + setError(null) + + try { + await declineInvitation(invitation.id) + onUpdate?.() + router.refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to decline invitation') + setIsDeclining(false) + } + } + + const formatDate = (date: Date) => { + const now = new Date() + const diffMs = now.getTime() - new Date(date).getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + if (diffMins < 1) return 'Just now' + if (diffMins < 60) return `${diffMins}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays < 7) return `${diffDays}d ago` + return new Date(date).toLocaleDateString() + } + + return ( +
+
+
+
+

+ {invitation.list.title} +

+

+ Invited by {invitation.inviter.name || invitation.inviter.email} +

+
+ +
+ +
+ + {formatDate(invitation.createdAt)} +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ ) +} diff --git a/components/PermissionBadge.tsx b/components/PermissionBadge.tsx new file mode 100644 index 0000000..b0043d4 --- /dev/null +++ b/components/PermissionBadge.tsx @@ -0,0 +1,43 @@ +'use client' + +import { Shield, Eye, Edit3 } from 'lucide-react' + +type Permission = 'owner' | 'editor' | 'viewer' + +interface PermissionBadgeProps { + permission: Permission + size?: 'sm' | 'md' +} + +export default function PermissionBadge({ permission, size = 'md' }: PermissionBadgeProps) { + const config = { + owner: { + icon: Shield, + label: 'Owner', + className: 'bg-purple-100 text-purple-700 border-purple-200', + }, + editor: { + icon: Edit3, + label: 'Editor', + className: 'bg-blue-100 text-blue-700 border-blue-200', + }, + viewer: { + icon: Eye, + label: 'Viewer', + className: 'bg-gray-100 text-gray-700 border-gray-200', + }, + } + + const { icon: Icon, label, className } = config[permission] + const sizeClasses = size === 'sm' ? 'text-xs px-2 py-1' : 'text-sm px-3 py-1.5' + const iconSize = size === 'sm' ? 12 : 14 + + return ( + + + {label} + + ) +} diff --git a/components/ShareListModal.tsx b/components/ShareListModal.tsx new file mode 100644 index 0000000..4547a2b --- /dev/null +++ b/components/ShareListModal.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useState } from 'react' +import { Users, Mail, Send } from 'lucide-react' +import Modal from './Modal' +import CollaboratorList from './CollaboratorList' +import { inviteUserToList, getCollaborators } from '@/lib/sharing-actions' +import { useRouter } from 'next/navigation' + +interface Collaborator { + id: string + permission: 'editor' | 'viewer' + user: { + id: string + name: string | null + email: string + } +} + +interface ShareListModalProps { + isOpen: boolean + onClose: () => void + listId: string + listTitle: string + isOwner: boolean + ownerId: string + ownerName: string | null + ownerEmail: string + initialCollaborators?: Collaborator[] +} + +export default function ShareListModal({ + isOpen, + onClose, + listId, + listTitle, + isOwner, + ownerId, + ownerName, + ownerEmail, + initialCollaborators = [], +}: ShareListModalProps) { + const [email, setEmail] = useState('') + const [permission, setPermission] = useState<'editor' | 'viewer'>('editor') + const [isInviting, setIsInviting] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [collaborators, setCollaborators] = useState(initialCollaborators) + const router = useRouter() + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault() + setIsInviting(true) + setError(null) + setSuccess(null) + + try { + await inviteUserToList(listId, email, permission) + setSuccess(`Invitation sent to ${email}`) + setEmail('') + setPermission('editor') + + // Refresh collaborators list + const updatedCollaborators = await getCollaborators(listId) as Collaborator[] + setCollaborators(updatedCollaborators) + router.refresh() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send invitation') + } finally { + setIsInviting(false) + } + } + + const handleClose = () => { + setEmail('') + setPermission('editor') + setError(null) + setSuccess(null) + onClose() + } + + return ( + +
+ {/* Invite Form - Only show if user is owner */} + {isOwner && ( +
+
+ +

Invite Collaborator

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="colleague@example.com" + required + disabled={isInviting} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" + /> +
+ +
+ + +
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + + +
+
+ )} + + {/* Collaborators List */} +
+
+ +

+ {isOwner ? 'People with Access' : 'Collaborators'} +

+
+ + +
+
+
+ ) +}