feat: update components for multi-user support

- Add sharing indicator to ListCard showing collaborator count
- Add permission badge and share button to ListDetailView
- Disable editing in ItemForm for viewers with visual feedback
- Add permission checks to item detail and edit pages
- Hide edit/delete buttons for viewers on item detail page
- Pass permission and collaborator data through component hierarchy
- Update navigation to include profile and invitations links (already done in previous tasks)

Implements task 15 from user-auth-and-sharing spec
This commit is contained in:
2025-12-02 01:14:21 +00:00
parent 85c8a1650b
commit 54612a4782
7 changed files with 215 additions and 40 deletions

View File

@@ -26,9 +26,10 @@ interface ItemFormProps {
notes: string | null
}
mode?: 'create' | 'edit'
canEdit?: boolean
}
export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps) {
export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }: ItemFormProps) {
const router = useRouter()
const { showToast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -157,6 +158,11 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!canEdit) {
showToast('You do not have permission to edit this item', 'error')
return
}
if (!validateForm()) {
showToast('Please fix validation errors', 'error')
return
@@ -238,6 +244,14 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
return (
<form onSubmit={handleSubmit} className="space-y-5 sm:space-y-6">
{!canEdit && (
<div className="p-3 sm:p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-xs sm:text-sm text-yellow-800">
You have view-only access to this list. You cannot edit items.
</p>
</div>
)}
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
@@ -249,11 +263,12 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => handleBlur('title')}
disabled={!canEdit}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
errors.title && touched.title
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
placeholder="Enter task title"
maxLength={200}
aria-invalid={errors.title && touched.title ? 'true' : 'false'}
@@ -276,12 +291,13 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={description}
onChange={(e) => setDescription(e.target.value)}
onBlur={() => handleBlur('description')}
disabled={!canEdit}
rows={3}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${
errors.description && touched.description
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
placeholder="Add a description"
maxLength={2000}
aria-invalid={errors.description && touched.description ? 'true' : 'false'}
@@ -303,7 +319,8 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
id="priority"
value={priority}
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target"
disabled={!canEdit}
className={`w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
@@ -322,11 +339,12 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
onBlur={() => handleBlur('dueDate')}
disabled={!canEdit}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base tap-target ${
errors.dueDate && touched.dueDate
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
min="1900-01-01"
max="2100-12-31"
aria-invalid={errors.dueDate && touched.dueDate ? 'true' : 'false'}
@@ -351,11 +369,12 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={!canEdit}
className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base ${
errors.tagInput
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
placeholder="Add a tag"
maxLength={50}
aria-invalid={errors.tagInput ? 'true' : 'false'}
@@ -364,7 +383,7 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
<button
type="button"
onClick={handleAddTag}
disabled={tags.length >= 20}
disabled={tags.length >= 20 || !canEdit}
className="px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 disabled:bg-gray-100 disabled:text-gray-400 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
>
Add
@@ -389,7 +408,8 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-blue-600 active:text-blue-700 tap-target-sm text-lg leading-none"
disabled={!canEdit}
className="hover:text-blue-600 active:text-blue-700 tap-target-sm text-lg leading-none disabled:opacity-50 disabled:cursor-not-allowed"
aria-label={`Remove ${tag} tag`}
>
×
@@ -410,12 +430,13 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
value={notes}
onChange={(e) => setNotes(e.target.value)}
onBlur={() => handleBlur('notes')}
disabled={!canEdit}
rows={5}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-sm sm:text-base resize-y ${
errors.notes && touched.notes
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
}`}
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
placeholder="Add additional notes"
maxLength={5000}
aria-invalid={errors.notes && touched.notes ? 'true' : 'false'}
@@ -442,15 +463,17 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
onClick={() => router.back()}
className="w-full sm:w-auto px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="w-full sm:flex-1 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:bg-blue-300 text-white px-4 sm:px-5 py-3 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Saving...' : mode === 'create' ? 'Create Task' : 'Save Changes'}
{canEdit ? 'Cancel' : 'Back'}
</button>
{canEdit && (
<button
type="submit"
disabled={isSubmitting}
className="w-full sm:flex-1 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:bg-blue-300 text-white px-4 sm:px-5 py-3 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Saving...' : mode === 'create' ? 'Create Task' : 'Save Changes'}
</button>
)}
</div>
</form>
)

View File

@@ -2,7 +2,7 @@
import { memo, useState } from 'react'
import Link from 'next/link'
import { Edit2 } from 'lucide-react'
import { Edit2, Users } from 'lucide-react'
import Modal from './Modal'
import ListForm from './ListForm'
import DeleteListButton from './DeleteListButton'
@@ -15,6 +15,11 @@ interface List {
id: string
completed: boolean
}>
collaborators?: Array<{
id: string
userId: string
permission: string
}>
}
function ListCard({ list }: { list: List }) {
@@ -22,6 +27,7 @@ function ListCard({ list }: { list: List }) {
const completedCount = list.items.filter(item => item.completed).length
const totalCount = list.items.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
const hasCollaborators = list.collaborators && list.collaborators.length > 0
const handleEditClick = (e: React.MouseEvent) => {
e.preventDefault()
@@ -45,6 +51,12 @@ function ListCard({ list }: { list: List }) {
style={{ backgroundColor: list.color }}
/>
<h3 className="font-semibold text-gray-900 truncate text-base sm:text-lg">{list.title}</h3>
{hasCollaborators && list.collaborators && (
<div className="flex items-center gap-1 text-gray-500 flex-shrink-0" title={`Shared with ${list.collaborators.length} ${list.collaborators.length === 1 ? 'person' : 'people'}`}>
<Users size={16} />
<span className="text-xs">{list.collaborators.length}</span>
</div>
)}
</div>
<div className="flex items-center gap-0.5 sm:gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={handleDeleteClick}>
<button

View File

@@ -1,11 +1,14 @@
'use client'
import { useState, useCallback, useMemo, useEffect } from 'react'
import { Share2 } from 'lucide-react'
import ItemCard from './ItemCard'
import SearchBar from './SearchBar'
import FilterPanel from './FilterPanel'
import SearchResults from './SearchResults'
import EmptyState from './EmptyState'
import ShareListModal from './ShareListModal'
import PermissionBadge from './PermissionBadge'
import { searchItems, getItemsByFilter } from '@/lib/actions'
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
@@ -34,12 +37,38 @@ interface ItemWithList extends Item {
interface ListDetailViewProps {
listId: string
initialItems: Item[]
permission?: 'owner' | 'editor' | 'viewer'
canShare?: boolean
listTitle?: string
ownerId?: string
ownerName?: string | null
ownerEmail?: string
collaborators?: Array<{
id: string
permission: 'editor' | 'viewer'
user: {
id: string
name: string | null
email: string
}
}>
}
export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) {
export default function ListDetailView({
listId,
initialItems,
permission,
canShare = false,
listTitle = '',
ownerId = '',
ownerName = null,
ownerEmail = '',
collaborators = []
}: ListDetailViewProps) {
const [searchResults, setSearchResults] = useState<ItemWithList[]>([])
const [isSearching, setIsSearching] = useState(false)
const [filteredItems, setFilteredItems] = useState<Item[]>(initialItems)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
// Use scroll position preservation hook
useScrollPosition({ key: `list-${listId}` })
@@ -118,6 +147,26 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
return (
<div className="space-y-6">
{/* Permission Badge and Share Button */}
{(permission || canShare) && (
<div className="flex items-center justify-between gap-4">
{permission && (
<div>
<PermissionBadge permission={permission} />
</div>
)}
{canShare && (
<button
onClick={() => setIsShareModalOpen(true)}
className="ml-auto flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
>
<Share2 size={16} />
Share
</button>
)}
</div>
)}
{/* Search Bar */}
<SearchBar
onSearch={handleSearchCallback}
@@ -181,6 +230,19 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
)}
</div>
)}
{/* Share Modal */}
<ShareListModal
isOpen={isShareModalOpen}
onClose={() => setIsShareModalOpen(false)}
listId={listId}
listTitle={listTitle}
isOwner={permission === 'owner'}
ownerId={ownerId}
ownerName={ownerName}
ownerEmail={ownerEmail}
initialCollaborators={collaborators}
/>
</div>
)
}