feat: slide panels, task calendar, team management, project editing, collapsible sections
- Add SlidePanel, TaskDetailPanel, PostDetailPanel, TeamPanel, TeamMemberPanel - Add ProjectEditPanel, CollapsibleSection, DatePresetPicker, TaskCalendarView - Update App, AuthContext, i18n (ar/en), PostProduction, ProjectDetail, Projects - Update Settings, Tasks, Team pages - Update InteractiveTimeline, MemberCard, ProjectCard, TaskCard components - Update server API utilities - Remove tracked server/node_modules (now properly gitignored)
This commit is contained in:
@@ -10,6 +10,7 @@ import Assets from './pages/Assets'
|
|||||||
import Campaigns from './pages/Campaigns'
|
import Campaigns from './pages/Campaigns'
|
||||||
import CampaignDetail from './pages/CampaignDetail'
|
import CampaignDetail from './pages/CampaignDetail'
|
||||||
import Finance from './pages/Finance'
|
import Finance from './pages/Finance'
|
||||||
|
import Budgets from './pages/Budgets'
|
||||||
import Projects from './pages/Projects'
|
import Projects from './pages/Projects'
|
||||||
import ProjectDetail from './pages/ProjectDetail'
|
import ProjectDetail from './pages/ProjectDetail'
|
||||||
import Tasks from './pages/Tasks'
|
import Tasks from './pages/Tasks'
|
||||||
@@ -40,10 +41,11 @@ const TEAM_ROLES = [
|
|||||||
export const AppContext = createContext()
|
export const AppContext = createContext()
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const { user, loading: authLoading, checkAuth } = useAuth()
|
const { user, loading: authLoading, checkAuth, hasModule } = useAuth()
|
||||||
const { t, lang } = useLanguage()
|
const { t, lang } = useLanguage()
|
||||||
const [teamMembers, setTeamMembers] = useState([])
|
const [teamMembers, setTeamMembers] = useState([])
|
||||||
const [brands, setBrands] = useState([])
|
const [brands, setBrands] = useState([])
|
||||||
|
const [teams, setTeams] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showTutorial, setShowTutorial] = useState(false)
|
const [showTutorial, setShowTutorial] = useState(false)
|
||||||
const [showProfilePrompt, setShowProfilePrompt] = useState(false)
|
const [showProfilePrompt, setShowProfilePrompt] = useState(false)
|
||||||
@@ -88,11 +90,21 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadTeams = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/teams')
|
||||||
|
setTeams(Array.isArray(data) ? data : (data.data || []))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load teams:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadInitialData = async () => {
|
const loadInitialData = async () => {
|
||||||
try {
|
try {
|
||||||
const [, brandsData] = await Promise.all([
|
const [, brandsData] = await Promise.all([
|
||||||
loadTeam(),
|
loadTeam(),
|
||||||
api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []),
|
api.get('/brands').then(d => Array.isArray(d) ? d : (d.data || [])).catch(() => []),
|
||||||
|
loadTeams(),
|
||||||
])
|
])
|
||||||
setBrands(brandsData)
|
setBrands(brandsData)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -123,7 +135,7 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName }}>
|
<AppContext.Provider value={{ currentUser: user, teamMembers, brands, loadTeam, getBrandName, teams, loadTeams }}>
|
||||||
{/* Profile completion prompt */}
|
{/* Profile completion prompt */}
|
||||||
{showProfilePrompt && (
|
{showProfilePrompt && (
|
||||||
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
<div className="fixed top-4 right-4 z-50 bg-amber-50 border-2 border-amber-400 rounded-xl shadow-lg p-4 max-w-md animate-fade-in">
|
||||||
@@ -258,18 +270,23 @@ function AppContent() {
|
|||||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
|
||||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="posts" element={<PostProduction />} />
|
{hasModule('marketing') && <>
|
||||||
<Route path="assets" element={<Assets />} />
|
<Route path="posts" element={<PostProduction />} />
|
||||||
<Route path="campaigns" element={<Campaigns />} />
|
<Route path="assets" element={<Assets />} />
|
||||||
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
<Route path="campaigns" element={<Campaigns />} />
|
||||||
{(user?.role === 'superadmin' || user?.role === 'manager') && (
|
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
||||||
|
<Route path="brands" element={<Brands />} />
|
||||||
|
</>}
|
||||||
|
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
|
||||||
<Route path="finance" element={<Finance />} />
|
<Route path="finance" element={<Finance />} />
|
||||||
)}
|
<Route path="budgets" element={<Budgets />} />
|
||||||
<Route path="projects" element={<Projects />} />
|
</>}
|
||||||
<Route path="projects/:id" element={<ProjectDetail />} />
|
{hasModule('projects') && <>
|
||||||
<Route path="tasks" element={<Tasks />} />
|
<Route path="projects" element={<Projects />} />
|
||||||
|
<Route path="projects/:id" element={<ProjectDetail />} />
|
||||||
|
<Route path="tasks" element={<Tasks />} />
|
||||||
|
</>}
|
||||||
<Route path="team" element={<Team />} />
|
<Route path="team" element={<Team />} />
|
||||||
<Route path="brands" element={<Brands />} />
|
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
{user?.role === 'superadmin' && (
|
{user?.role === 'superadmin' && (
|
||||||
<Route path="users" element={<Users />} />
|
<Route path="users" element={<Users />} />
|
||||||
|
|||||||
20
client/src/components/CollapsibleSection.jsx
Normal file
20
client/src/components/CollapsibleSection.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function CollapsibleSection({ title, defaultOpen = true, badge, children, noBorder }) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={noBorder ? '' : 'border-b border-border'}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="w-full flex items-center gap-2 px-5 py-3 text-sm font-semibold text-text-primary hover:bg-surface-secondary transition-colors"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||||
|
{title}
|
||||||
|
{badge}
|
||||||
|
</button>
|
||||||
|
{open && children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
client/src/components/DatePresetPicker.jsx
Normal file
37
client/src/components/DatePresetPicker.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { DATE_PRESETS } from '../utils/datePresets'
|
||||||
|
|
||||||
|
export default function DatePresetPicker({ onSelect, activePreset, onClear }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
{DATE_PRESETS.map(preset => (
|
||||||
|
<button
|
||||||
|
key={preset.key}
|
||||||
|
onClick={() => {
|
||||||
|
const { from, to } = preset.getRange()
|
||||||
|
onSelect(from, to, preset.key)
|
||||||
|
}}
|
||||||
|
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
|
||||||
|
activePreset === preset.key
|
||||||
|
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary'
|
||||||
|
: 'bg-white border-border text-text-tertiary hover:text-text-primary hover:border-border-dark'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(preset.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{activePreset && (
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="p-1 text-text-tertiary hover:text-text-primary transition-colors"
|
||||||
|
title={t('dates.clearDates')}
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -313,11 +313,15 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{item.assigneeName && (
|
{item.thumbnailUrl ? (
|
||||||
|
<div className="w-8 h-8 rounded overflow-hidden shrink-0">
|
||||||
|
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : item.assigneeName ? (
|
||||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||||||
{getInitials(item.assigneeName)}
|
{getInitials(item.assigneeName)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
<span className="text-sm font-semibold text-text-primary truncate">{item.label}</span>
|
<span className="text-sm font-semibold text-text-primary truncate">{item.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{item.description && (
|
{item.description && (
|
||||||
@@ -333,11 +337,15 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{item.assigneeName && (
|
{item.thumbnailUrl ? (
|
||||||
|
<div className="w-6 h-6 rounded overflow-hidden shrink-0">
|
||||||
|
<img src={item.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : item.assigneeName ? (
|
||||||
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
<div className="w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[9px] font-bold shrink-0">
|
||||||
{getInitials(item.assigneeName)}
|
{getInitials(item.assigneeName)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
<span className="text-xs font-medium text-text-primary truncate">{item.label}</span>
|
<span className="text-xs font-medium text-text-primary truncate">{item.label}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ export default function MemberCard({ member, onClick }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Teams */}
|
||||||
|
{member.teams && member.teams.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 justify-center mt-2">
|
||||||
|
{member.teams.map((team) => (
|
||||||
|
<span key={team.id} className="text-[10px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||||
|
{team.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
582
client/src/components/PostDetailPanel.jsx
Normal file
582
client/src/components/PostDetailPanel.jsx
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { api, PLATFORMS, getBrandColor } from '../utils/api'
|
||||||
|
import CommentsSection from './CommentsSection'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import SlidePanel from './SlidePanel'
|
||||||
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
|
|
||||||
|
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
|
||||||
|
const { t, lang } = useLanguage()
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
const [form, setForm] = useState({})
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [publishError, setPublishError] = useState('')
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
|
// Attachments state
|
||||||
|
const [attachments, setAttachments] = useState([])
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
||||||
|
const [availableAssets, setAvailableAssets] = useState([])
|
||||||
|
const [assetSearch, setAssetSearch] = useState('')
|
||||||
|
|
||||||
|
const postId = post?._id || post?.id
|
||||||
|
const isCreateMode = !postId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (post) {
|
||||||
|
setForm({
|
||||||
|
title: post.title || '',
|
||||||
|
description: post.description || '',
|
||||||
|
brand_id: post.brandId || post.brand_id || '',
|
||||||
|
platforms: post.platforms || (post.platform ? [post.platform] : []),
|
||||||
|
status: post.status || 'draft',
|
||||||
|
assigned_to: post.assignedTo || post.assigned_to || '',
|
||||||
|
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
|
||||||
|
notes: post.notes || '',
|
||||||
|
campaign_id: post.campaignId || post.campaign_id || '',
|
||||||
|
publication_links: post.publication_links || post.publicationLinks || [],
|
||||||
|
})
|
||||||
|
setDirty(isCreateMode)
|
||||||
|
setPublishError('')
|
||||||
|
if (!isCreateMode) loadAttachments()
|
||||||
|
}
|
||||||
|
}, [post])
|
||||||
|
|
||||||
|
if (!post) return null
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'draft', label: t('posts.status.draft') },
|
||||||
|
{ value: 'in_review', label: t('posts.status.in_review') },
|
||||||
|
{ value: 'approved', label: t('posts.status.approved') },
|
||||||
|
{ value: 'scheduled', label: t('posts.status.scheduled') },
|
||||||
|
{ value: 'published', label: t('posts.status.published') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const update = (field, value) => {
|
||||||
|
setForm(f => ({ ...f, [field]: value }))
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePublicationLink = (platform, url) => {
|
||||||
|
setForm(f => {
|
||||||
|
const links = [...(f.publication_links || [])]
|
||||||
|
const idx = links.findIndex(l => l.platform === platform)
|
||||||
|
if (idx >= 0) {
|
||||||
|
links[idx] = { ...links[idx], url }
|
||||||
|
} else {
|
||||||
|
links.push({ platform, url })
|
||||||
|
}
|
||||||
|
return { ...f, publication_links: links }
|
||||||
|
})
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setPublishError('')
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
||||||
|
assigned_to: form.assigned_to ? Number(form.assigned_to) : null,
|
||||||
|
status: form.status,
|
||||||
|
platforms: form.platforms || [],
|
||||||
|
scheduled_date: form.scheduled_date || null,
|
||||||
|
notes: form.notes,
|
||||||
|
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
|
||||||
|
publication_links: form.publication_links || [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'published' && data.platforms.length > 0) {
|
||||||
|
const missingPlatforms = data.platforms.filter(platform => {
|
||||||
|
const link = (data.publication_links || []).find(l => l.platform === platform)
|
||||||
|
return !link || !link.url || !link.url.trim()
|
||||||
|
})
|
||||||
|
if (missingPlatforms.length > 0) {
|
||||||
|
const names = missingPlatforms.map(p => PLATFORMS[p]?.label || p).join(', ')
|
||||||
|
setPublishError(`${t('posts.publishMissing')} ${names}`)
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSave(isCreateMode ? null : postId, data)
|
||||||
|
setDirty(false)
|
||||||
|
if (isCreateMode) onClose()
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message?.includes('Cannot publish')) {
|
||||||
|
setPublishError(err.message.replace(/.*: /, ''))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
await onDelete(postId)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Attachments ──────────────────────────────
|
||||||
|
async function loadAttachments() {
|
||||||
|
if (!postId) return
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/posts/${postId}/attachments`)
|
||||||
|
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||||
|
} catch {
|
||||||
|
setAttachments([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUpload = async (files) => {
|
||||||
|
if (!postId || !files?.length) return
|
||||||
|
setUploading(true)
|
||||||
|
for (const file of files) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
try {
|
||||||
|
await api.upload(`/posts/${postId}/attachments`, fd)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUploading(false)
|
||||||
|
loadAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteAttachment = async (attId) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/attachments/${attId}`)
|
||||||
|
loadAttachments()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete attachment failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAssetPicker = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/assets')
|
||||||
|
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
|
||||||
|
} catch {
|
||||||
|
setAvailableAssets([])
|
||||||
|
}
|
||||||
|
setAssetSearch('')
|
||||||
|
setShowAssetPicker(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAttachAsset = async (assetId) => {
|
||||||
|
if (!postId) return
|
||||||
|
try {
|
||||||
|
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
|
||||||
|
loadAttachments()
|
||||||
|
setShowAssetPicker(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Attach asset failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
||||||
|
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const brandName = (() => {
|
||||||
|
if (form.brand_id) {
|
||||||
|
const b = brands?.find(b => String(b._id || b.id) === String(form.brand_id))
|
||||||
|
return b ? (lang === 'ar' && b.name_ar ? b.name_ar : b.name) : null
|
||||||
|
}
|
||||||
|
return post.brand_name || post.brandName || null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.title}
|
||||||
|
onChange={e => update('title', e.target.value)}
|
||||||
|
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||||
|
placeholder={t('posts.postTitlePlaceholder')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
form.status === 'published' ? 'bg-emerald-100 text-emerald-700' :
|
||||||
|
form.status === 'scheduled' ? 'bg-purple-100 text-purple-700' :
|
||||||
|
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{statusOptions.find(s => s.value === form.status)?.label}
|
||||||
|
</span>
|
||||||
|
{brandName && (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||||
|
{brandName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
|
||||||
|
{/* Details Section */}
|
||||||
|
<CollapsibleSection title={t('posts.details')}>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => update('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||||
|
placeholder={t('posts.postDescPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.brand')}</label>
|
||||||
|
<select
|
||||||
|
value={form.brand_id}
|
||||||
|
onChange={e => update('brand_id', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="">{t('posts.selectBrand')}</option>
|
||||||
|
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.campaign')}</label>
|
||||||
|
<select
|
||||||
|
value={form.campaign_id}
|
||||||
|
onChange={e => update('campaign_id', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="">{t('posts.noCampaign')}</option>
|
||||||
|
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignTo')}</label>
|
||||||
|
<select
|
||||||
|
value={form.assigned_to}
|
||||||
|
onChange={e => update('assigned_to', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="">{t('common.unassigned')}</option>
|
||||||
|
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.status')}</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={e => update('status', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.scheduledDate')}</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.scheduled_date}
|
||||||
|
onChange={e => update('scheduled_date', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.notes')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.notes}
|
||||||
|
onChange={e => update('notes', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder={t('posts.additionalNotes')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{publishError && (
|
||||||
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||||
|
{publishError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.title || saving}
|
||||||
|
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||||
|
>
|
||||||
|
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && !isCreateMode && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Platforms & Links Section */}
|
||||||
|
<CollapsibleSection title={t('posts.platformsLinks')}>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.platforms')}</label>
|
||||||
|
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
|
||||||
|
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||||
|
const checked = (form.platforms || []).includes(k)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={k}
|
||||||
|
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
||||||
|
checked
|
||||||
|
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
||||||
|
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
update('platforms', checked
|
||||||
|
? form.platforms.filter(p => p !== k)
|
||||||
|
: [...(form.platforms || []), k]
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
{v.label}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(form.platforms || []).length > 0 && (
|
||||||
|
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-1">
|
||||||
|
<Link2 className="w-3.5 h-3.5" />
|
||||||
|
{t('posts.publicationLinks')}
|
||||||
|
</div>
|
||||||
|
{(form.platforms || []).map(platformKey => {
|
||||||
|
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
||||||
|
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
|
||||||
|
const linkUrl = existingLink?.url || ''
|
||||||
|
return (
|
||||||
|
<div key={platformKey} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
|
||||||
|
{platformInfo.label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={e => updatePublicationLink(platformKey, e.target.value)}
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
{linkUrl && (
|
||||||
|
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{form.status === 'published' && (form.platforms || []).some(p => {
|
||||||
|
const link = (form.publication_links || []).find(l => l.platform === p)
|
||||||
|
return !link || !link.url?.trim()
|
||||||
|
}) && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1">{t('posts.publishRequired')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Attachments Section (hidden in create mode) */}
|
||||||
|
{!isCreateMode && (
|
||||||
|
<CollapsibleSection
|
||||||
|
title={t('posts.attachments')}
|
||||||
|
badge={attachments.length > 0 ? (
|
||||||
|
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||||
|
{attachments.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
>
|
||||||
|
<div className="px-5 pb-4">
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
{attachments.map(att => {
|
||||||
|
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||||
|
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||||
|
const name = att.original_name || att.originalName || att.filename
|
||||||
|
const attId = att.id || att._id
|
||||||
|
return (
|
||||||
|
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||||
|
<div className="h-20 relative">
|
||||||
|
{isImage ? (
|
||||||
|
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||||
|
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||||
|
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||||
|
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteAttachment(attId)}
|
||||||
|
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<X className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
||||||
|
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
||||||
|
}`}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
|
||||||
|
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
|
||||||
|
onDragOver={e => e.preventDefault()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }}
|
||||||
|
/>
|
||||||
|
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openAssetPicker}
|
||||||
|
className="mt-2 flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
{t('posts.attachFromAssets')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAssetPicker && (
|
||||||
|
<div className="mt-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
|
||||||
|
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={assetSearch}
|
||||||
|
onChange={e => setAssetSearch(e.target.value)}
|
||||||
|
placeholder={t('common.search')}
|
||||||
|
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
|
||||||
|
{availableAssets
|
||||||
|
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
|
||||||
|
.map(asset => {
|
||||||
|
const isImage = asset.mime_type?.startsWith('image/')
|
||||||
|
const assetUrl = `/api/uploads/${asset.filename}`
|
||||||
|
const name = asset.original_name || asset.filename
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={asset.id || asset._id}
|
||||||
|
onClick={() => handleAttachAsset(asset.id || asset._id)}
|
||||||
|
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
|
||||||
|
>
|
||||||
|
<div className="aspect-square relative">
|
||||||
|
{isImage ? (
|
||||||
|
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
|
||||||
|
<FileText className="w-6 h-6 text-text-tertiary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
|
||||||
|
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Discussion Section (hidden in create mode) */}
|
||||||
|
{!isCreateMode && (
|
||||||
|
<CollapsibleSection title={t('posts.discussion')} noBorder>
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<CommentsSection entityType="post" entityId={postId} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
</SlidePanel>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onClose={() => setShowDeleteConfirm(false)}
|
||||||
|
title={t('posts.deletePost')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('posts.deletePost')}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{t('posts.deleteConfirm')}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,11 +16,19 @@ export default function ProjectCard({ project }) {
|
|||||||
|
|
||||||
const ownerName = typeof project.owner === 'object' ? project.owner?.name : project.owner
|
const ownerName = typeof project.owner === 'object' ? project.owner?.name : project.owner
|
||||||
|
|
||||||
|
const thumbnailUrl = project.thumbnail_url || project.thumbnailUrl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => navigate(`/projects/${project._id}`)}
|
onClick={() => navigate(`/projects/${project._id}`)}
|
||||||
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer"
|
className="bg-white rounded-xl border border-border card-hover cursor-pointer overflow-hidden"
|
||||||
>
|
>
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
<div className="w-full h-32 overflow-hidden">
|
||||||
|
<img src={thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="p-5">
|
||||||
<div className="flex items-start justify-between gap-3 mb-3">
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
<h4 className="text-base font-semibold text-text-primary line-clamp-1">{project.name}</h4>
|
<h4 className="text-base font-semibold text-text-primary line-clamp-1">{project.name}</h4>
|
||||||
<StatusBadge status={project.status} size="xs" />
|
<StatusBadge status={project.status} size="xs" />
|
||||||
@@ -67,6 +75,7 @@ export default function ProjectCard({ project }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>{/* end p-5 wrapper */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
304
client/src/components/ProjectEditPanel.jsx
Normal file
304
client/src/components/ProjectEditPanel.jsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { X, Trash2, Upload } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { api, getBrandColor } from '../utils/api'
|
||||||
|
import CommentsSection from './CommentsSection'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import SlidePanel from './SlidePanel'
|
||||||
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
|
|
||||||
|
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
|
||||||
|
const { t, lang } = useLanguage()
|
||||||
|
const thumbnailInputRef = useRef(null)
|
||||||
|
const [form, setForm] = useState({})
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
||||||
|
|
||||||
|
const projectId = project?._id || project?.id
|
||||||
|
if (!project) return null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (project) {
|
||||||
|
setForm({
|
||||||
|
name: project.name || '',
|
||||||
|
description: project.description || '',
|
||||||
|
brand_id: project.brandId || project.brand_id || '',
|
||||||
|
owner_id: project.ownerId || project.owner_id || '',
|
||||||
|
status: project.status || 'active',
|
||||||
|
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
|
||||||
|
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
|
||||||
|
})
|
||||||
|
setDirty(false)
|
||||||
|
}
|
||||||
|
}, [project])
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'active', label: 'Active' },
|
||||||
|
{ value: 'paused', label: 'Paused' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const update = (field, value) => {
|
||||||
|
setForm(f => ({ ...f, [field]: value }))
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(projectId, {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
brand_id: form.brand_id ? Number(form.brand_id) : null,
|
||||||
|
owner_id: form.owner_id ? Number(form.owner_id) : null,
|
||||||
|
status: form.status,
|
||||||
|
start_date: form.start_date || null,
|
||||||
|
due_date: form.due_date || null,
|
||||||
|
})
|
||||||
|
setDirty(false)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
await onDelete(projectId)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThumbnailUpload = async (file) => {
|
||||||
|
if (!file) return
|
||||||
|
setThumbnailUploading(true)
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
await api.upload(`/projects/${projectId}/thumbnail`, fd)
|
||||||
|
// Parent will reload
|
||||||
|
onSave(projectId, form)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Thumbnail upload failed:', err)
|
||||||
|
} finally {
|
||||||
|
setThumbnailUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThumbnailRemove = async () => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/projects/${projectId}/thumbnail`)
|
||||||
|
onSave(projectId, form)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Thumbnail remove failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const brandName = (() => {
|
||||||
|
if (form.brand_id) {
|
||||||
|
const b = brands?.find(b => String(b._id || b.id) === String(form.brand_id))
|
||||||
|
return b ? (lang === 'ar' && b.name_ar ? b.name_ar : b.name) : null
|
||||||
|
}
|
||||||
|
return project.brand_name || project.brandName || null
|
||||||
|
})()
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => update('name', e.target.value)}
|
||||||
|
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||||
|
placeholder={t('projects.name')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
|
||||||
|
form.status === 'active' ? 'bg-emerald-100 text-emerald-700' :
|
||||||
|
form.status === 'paused' ? 'bg-amber-100 text-amber-700' :
|
||||||
|
form.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
form.status === 'cancelled' ? 'bg-red-100 text-red-700' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{statusOptions.find(s => s.value === form.status)?.label}
|
||||||
|
</span>
|
||||||
|
{brandName && (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||||
|
{brandName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||||
|
{/* Details Section */}
|
||||||
|
<CollapsibleSection title={t('projects.details')}>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.description')}</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => update('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||||
|
placeholder="Project description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.brand')}</label>
|
||||||
|
<select
|
||||||
|
value={form.brand_id}
|
||||||
|
onChange={e => update('brand_id', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="">Select brand</option>
|
||||||
|
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.status')}</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={e => update('status', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.owner')}</label>
|
||||||
|
<select
|
||||||
|
value={form.owner_id}
|
||||||
|
onChange={e => update('owner_id', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="">{t('common.unassigned')}</option>
|
||||||
|
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.startDate')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.start_date}
|
||||||
|
onChange={e => update('start_date', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.dueDate')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.due_date}
|
||||||
|
onChange={e => update('due_date', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.thumbnail')}</label>
|
||||||
|
{(project.thumbnail_url || project.thumbnailUrl) ? (
|
||||||
|
<div className="relative group rounded-lg overflow-hidden border border-border">
|
||||||
|
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-24 object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center gap-2 opacity-0 group-hover:opacity-100">
|
||||||
|
<button
|
||||||
|
onClick={() => thumbnailInputRef.current?.click()}
|
||||||
|
className="px-3 py-1.5 text-xs bg-white/90 hover:bg-white rounded-lg font-medium text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t('projects.changeThumbnail')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleThumbnailRemove}
|
||||||
|
className="px-3 py-1.5 text-xs bg-red-500/90 hover:bg-red-500 rounded-lg font-medium text-white transition-colors"
|
||||||
|
>
|
||||||
|
{t('projects.removeThumbnail')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => thumbnailInputRef.current?.click()}
|
||||||
|
disabled={thumbnailUploading}
|
||||||
|
className="w-full border-2 border-dashed border-border rounded-lg p-3 text-center hover:border-brand-primary/40 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${thumbnailUploading ? 'animate-pulse' : ''}`} />
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
{thumbnailUploading ? 'Uploading...' : t('projects.uploadThumbnail')}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={thumbnailInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.name || saving}
|
||||||
|
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||||
|
>
|
||||||
|
{t('tasks.saveChanges')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Discussion Section */}
|
||||||
|
<CollapsibleSection title={t('projects.discussion')} noBorder>
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<CommentsSection entityType="project" entityId={projectId} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</SlidePanel>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onClose={() => setShowDeleteConfirm(false)}
|
||||||
|
title={t('projects.deleteProject')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('common.delete')}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{t('projects.deleteConfirm')}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
client/src/components/SlidePanel.jsx
Normal file
19
client/src/components/SlidePanel.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
export default function SlidePanel({ onClose, maxWidth = '420px', header, children }) {
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 bg-black/20 z-[9998]" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className="fixed top-0 right-0 h-full w-full bg-white shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden"
|
||||||
|
style={{ maxWidth }}
|
||||||
|
>
|
||||||
|
{header}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
176
client/src/components/TaskCalendarView.jsx
Normal file
176
client/src/components/TaskCalendarView.jsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { PRIORITY_CONFIG } from '../utils/api'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
|
||||||
|
function getMonthData(year, month) {
|
||||||
|
const firstDay = new Date(year, month, 1).getDay()
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
const prevDays = new Date(year, month, 0).getDate()
|
||||||
|
|
||||||
|
const cells = []
|
||||||
|
// Previous month trailing days
|
||||||
|
for (let i = firstDay - 1; i >= 0; i--) {
|
||||||
|
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
|
||||||
|
}
|
||||||
|
// Current month
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
|
cells.push({ day: d, current: true, date: new Date(year, month, d) })
|
||||||
|
}
|
||||||
|
// Next month leading days
|
||||||
|
const remaining = 42 - cells.length
|
||||||
|
for (let d = 1; d <= remaining; d++) {
|
||||||
|
cells.push({ day: d, current: false, date: new Date(year, month + 1, d) })
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateKey(d) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskCalendarView({ tasks, onTaskClick }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const today = new Date()
|
||||||
|
const [year, setYear] = useState(today.getFullYear())
|
||||||
|
const [month, setMonth] = useState(today.getMonth())
|
||||||
|
|
||||||
|
const cells = getMonthData(year, month)
|
||||||
|
const todayKey = dateKey(today)
|
||||||
|
|
||||||
|
// Group tasks by due_date
|
||||||
|
const tasksByDate = {}
|
||||||
|
const unscheduled = []
|
||||||
|
for (const task of tasks) {
|
||||||
|
const dd = task.due_date || task.dueDate
|
||||||
|
if (dd) {
|
||||||
|
const key = dd.slice(0, 10) // yyyy-mm-dd
|
||||||
|
if (!tasksByDate[key]) tasksByDate[key] = []
|
||||||
|
tasksByDate[key].push(task)
|
||||||
|
} else {
|
||||||
|
unscheduled.push(task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
if (month === 0) { setMonth(11); setYear(y => y - 1) }
|
||||||
|
else setMonth(m => m - 1)
|
||||||
|
}
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
||||||
|
else setMonth(m => m + 1)
|
||||||
|
}
|
||||||
|
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
|
||||||
|
|
||||||
|
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
|
||||||
|
|
||||||
|
const getPillColor = (task) => {
|
||||||
|
const p = task.priority || 'medium'
|
||||||
|
if (p === 'urgent') return 'bg-red-500 text-white'
|
||||||
|
if (p === 'high') return 'bg-orange-400 text-white'
|
||||||
|
if (p === 'medium') return 'bg-amber-400 text-amber-900'
|
||||||
|
return 'bg-gray-300 text-gray-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Calendar grid */}
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* Nav */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={prevMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary min-w-[150px] text-center">{monthLabel}</h3>
|
||||||
|
<button onClick={nextMonth} className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={goToday} className="px-3 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
||||||
|
{t('tasks.today')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day headers */}
|
||||||
|
<div className="grid grid-cols-7 mb-1">
|
||||||
|
{DAYS.map(d => (
|
||||||
|
<div key={d} className="text-center text-[10px] font-medium text-text-tertiary uppercase py-1">
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cells */}
|
||||||
|
<div className="grid grid-cols-7 border-t border-l border-border">
|
||||||
|
{cells.map((cell, i) => {
|
||||||
|
const key = dateKey(cell.date)
|
||||||
|
const isToday = key === todayKey
|
||||||
|
const dayTasks = tasksByDate[key] || []
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`border-r border-b border-border min-h-[90px] p-1 ${
|
||||||
|
cell.current ? 'bg-white' : 'bg-surface-secondary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`text-[11px] font-medium mb-0.5 w-6 h-6 flex items-center justify-center rounded-full ${
|
||||||
|
isToday ? 'bg-brand-primary text-white' : cell.current ? 'text-text-primary' : 'text-text-tertiary'
|
||||||
|
}`}>
|
||||||
|
{cell.day}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{dayTasks.slice(0, 3).map(task => (
|
||||||
|
<button
|
||||||
|
key={task._id || task.id}
|
||||||
|
onClick={() => onTaskClick(task)}
|
||||||
|
className={`w-full text-left text-[10px] px-1.5 py-0.5 rounded truncate font-medium hover:opacity-80 transition-opacity ${
|
||||||
|
task.status === 'done' ? 'bg-emerald-100 text-emerald-700 line-through' : getPillColor(task)
|
||||||
|
}`}
|
||||||
|
title={task.title}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{dayTasks.length > 3 && (
|
||||||
|
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
||||||
|
+{dayTasks.length - 3} more
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unscheduled sidebar */}
|
||||||
|
{unscheduled.length > 0 && (
|
||||||
|
<div className="w-48 shrink-0">
|
||||||
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('tasks.unscheduled')}</h4>
|
||||||
|
<div className="space-y-1.5 max-h-[500px] overflow-y-auto">
|
||||||
|
{unscheduled.map(task => {
|
||||||
|
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={task._id || task.id}
|
||||||
|
onClick={() => onTaskClick(task)}
|
||||||
|
className="w-full text-left bg-white border border-border rounded-lg p-2 hover:border-brand-primary/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${priority.color} shrink-0`} />
|
||||||
|
<span className={`text-xs font-medium truncate ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import { ArrowRight, Clock, User, UserCheck } from 'lucide-react'
|
import { ArrowRight, Clock, User, UserCheck, MessageCircle } from 'lucide-react'
|
||||||
import { PRIORITY_CONFIG } from '../utils/api'
|
import { PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
@@ -8,7 +8,8 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
|||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { user: authUser } = useAuth()
|
const { user: authUser } = useAuth()
|
||||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||||
const projectName = typeof task.project === 'object' ? task.project?.name : task.projectName
|
const projectName = task.project_name || (typeof task.project === 'object' ? task.project?.name : task.projectName)
|
||||||
|
const brandName = task.brand_name || task.brandName
|
||||||
|
|
||||||
const nextStatus = {
|
const nextStatus = {
|
||||||
todo: 'in_progress',
|
todo: 'in_progress',
|
||||||
@@ -23,6 +24,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
|||||||
const dueDate = task.due_date || task.dueDate
|
const dueDate = task.due_date || task.dueDate
|
||||||
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
||||||
const creatorName = task.creator_user_name || task.creatorUserName
|
const creatorName = task.creator_user_name || task.creatorUserName
|
||||||
|
const commentCount = task.comment_count || task.commentCount || 0
|
||||||
|
|
||||||
// Determine if this task was assigned by someone else
|
// Determine if this task was assigned by someone else
|
||||||
const createdByUserId = task.created_by_user_id || task.createdByUserId
|
const createdByUserId = task.created_by_user_id || task.createdByUserId
|
||||||
@@ -56,6 +58,11 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||||
|
{showProject && brandName && (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||||
|
{brandName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{showProject && projectName && (
|
{showProject && projectName && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||||||
{projectName}
|
{projectName}
|
||||||
@@ -67,6 +74,12 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
|||||||
{format(new Date(dueDate), 'MMM d')}
|
{format(new Date(dueDate), 'MMM d')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{commentCount > 0 && (
|
||||||
|
<span className="text-[10px] flex items-center gap-0.5 text-text-tertiary">
|
||||||
|
<MessageCircle className="w-3 h-3" />
|
||||||
|
{commentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!isExternallyAssigned && creatorName && (
|
{!isExternallyAssigned && creatorName && (
|
||||||
<span className="text-[10px] flex items-center gap-1 text-text-tertiary">
|
<span className="text-[10px] flex items-center gap-1 text-text-tertiary">
|
||||||
<User className="w-3 h-3" />
|
<User className="w-3 h-3" />
|
||||||
@@ -81,7 +94,7 @@ export default function TaskCard({ task, onMove, showProject = true }) {
|
|||||||
{onMove && nextStatus[task.status] && (
|
{onMove && nextStatus[task.status] && (
|
||||||
<div className="mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={() => onMove(task._id || task.id, nextStatus[task.status])}
|
onClick={(e) => { e.stopPropagation(); onMove(task._id || task.id, nextStatus[task.status]) }}
|
||||||
className="text-[11px] text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"
|
className="text-[11px] text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{nextLabel[task.status]} <ArrowRight className="w-3 h-3" />
|
{nextLabel[task.status]} <ArrowRight className="w-3 h-3" />
|
||||||
|
|||||||
553
client/src/components/TaskDetailPanel.jsx
Normal file
553
client/src/components/TaskDetailPanel.jsx
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { X, Trash2, AlertCircle, Upload, FileText, Star } from 'lucide-react'
|
||||||
|
import { PRIORITY_CONFIG, getBrandColor, api } from '../utils/api'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import CommentsSection from './CommentsSection'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import SlidePanel from './SlidePanel'
|
||||||
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
|
|
||||||
|
const API_BASE = '/api'
|
||||||
|
|
||||||
|
export default function TaskDetailPanel({ task, onClose, onSave, onDelete, projects, users, brands }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: '', description: '', project_id: '', assigned_to: '',
|
||||||
|
priority: 'medium', status: 'todo', start_date: '', due_date: '',
|
||||||
|
})
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
|
// Attachments state
|
||||||
|
const [attachments, setAttachments] = useState([])
|
||||||
|
const [pendingFiles, setPendingFiles] = useState([]) // for create mode (no task ID yet)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||||
|
const [uploadError, setUploadError] = useState(null)
|
||||||
|
const [currentThumbnail, setCurrentThumbnail] = useState(null)
|
||||||
|
|
||||||
|
const taskId = task?._id || task?.id
|
||||||
|
const isCreateMode = !taskId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const taskIdRef = useRef(taskId)
|
||||||
|
useEffect(() => {
|
||||||
|
// Only reset form when switching to a different task (or initial mount)
|
||||||
|
const switched = taskIdRef.current !== taskId
|
||||||
|
taskIdRef.current = taskId
|
||||||
|
if (task && (switched || !form.title)) {
|
||||||
|
setForm({
|
||||||
|
title: task.title || '',
|
||||||
|
description: task.description || '',
|
||||||
|
project_id: task.project_id || task.projectId || '',
|
||||||
|
assigned_to: task.assigned_to || task.assignedTo || '',
|
||||||
|
priority: task.priority || 'medium',
|
||||||
|
status: task.status || 'todo',
|
||||||
|
start_date: task.start_date || task.startDate || '',
|
||||||
|
due_date: task.due_date || task.dueDate || '',
|
||||||
|
})
|
||||||
|
setDirty(isCreateMode)
|
||||||
|
if (switched) setPendingFiles([])
|
||||||
|
setCurrentThumbnail(task.thumbnail || null)
|
||||||
|
if (!isCreateMode) loadAttachments()
|
||||||
|
}
|
||||||
|
}, [task])
|
||||||
|
|
||||||
|
if (!task) return null
|
||||||
|
|
||||||
|
const dueDate = task.due_date || task.dueDate
|
||||||
|
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
||||||
|
const creatorName = task.creator_user_name || task.creatorUserName
|
||||||
|
const priority = PRIORITY_CONFIG[form.priority] || PRIORITY_CONFIG.medium
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'todo', label: t('tasks.todo') },
|
||||||
|
{ value: 'in_progress', label: t('tasks.in_progress') },
|
||||||
|
{ value: 'done', label: t('tasks.done') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const priorityOptions = [
|
||||||
|
{ value: 'low', label: t('tasks.priority.low') },
|
||||||
|
{ value: 'medium', label: t('tasks.priority.medium') },
|
||||||
|
{ value: 'high', label: t('tasks.priority.high') },
|
||||||
|
{ value: 'urgent', label: t('tasks.priority.urgent') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const update = (field, value) => {
|
||||||
|
setForm(f => ({ ...f, [field]: value }))
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
project_id: form.project_id || null,
|
||||||
|
assigned_to: form.assigned_to || null,
|
||||||
|
priority: form.priority,
|
||||||
|
status: form.status,
|
||||||
|
start_date: form.start_date || null,
|
||||||
|
due_date: form.due_date || null,
|
||||||
|
}
|
||||||
|
await onSave(isCreateMode ? null : taskId, data, pendingFiles)
|
||||||
|
setDirty(false)
|
||||||
|
setPendingFiles([])
|
||||||
|
if (isCreateMode) onClose()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
setShowDeleteConfirm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
onDelete(taskId)
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Attachments ──────────────────────────────
|
||||||
|
async function loadAttachments() {
|
||||||
|
if (!taskId) return
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/tasks/${taskId}/attachments`)
|
||||||
|
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||||
|
} catch {
|
||||||
|
setAttachments([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileUpload = async (files) => {
|
||||||
|
if (!files?.length) return
|
||||||
|
setUploadError(null)
|
||||||
|
const maxBytes = maxSizeMB * 1024 * 1024
|
||||||
|
const tooBig = Array.from(files).find(f => f.size > maxBytes)
|
||||||
|
if (tooBig) {
|
||||||
|
setUploadError(t('tasks.fileTooLarge')
|
||||||
|
.replace('{name}', tooBig.name)
|
||||||
|
.replace('{size}', (tooBig.size / 1024 / 1024).toFixed(1))
|
||||||
|
.replace('{max}', maxSizeMB))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
for (const file of files) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
try {
|
||||||
|
await api.upload(`/tasks/${taskId}/attachments`, fd)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Upload failed:', err)
|
||||||
|
setUploadError(err.message || 'Upload failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setUploading(false)
|
||||||
|
loadAttachments()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteAttachment = async (attId) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/task-attachments/${attId}`)
|
||||||
|
loadAttachments()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete attachment failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetThumbnail = async (attachment) => {
|
||||||
|
try {
|
||||||
|
const attId = attachment._id || attachment.id || attachment.Id
|
||||||
|
await api.patch(`/tasks/${taskId}/thumbnail`, { attachment_id: attId })
|
||||||
|
const url = attachment.url || `/api/uploads/${attachment.filename}`
|
||||||
|
setCurrentThumbnail(url)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Set thumbnail failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveThumbnail = async () => {
|
||||||
|
try {
|
||||||
|
await api.patch(`/tasks/${taskId}/thumbnail`, { attachment_id: null })
|
||||||
|
setCurrentThumbnail(null)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Remove thumbnail failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get brand for the selected project
|
||||||
|
const selectedProject = projects?.find(p => String(p._id || p.id) === String(form.project_id))
|
||||||
|
const brandName = selectedProject ? (selectedProject.brand_name || selectedProject.brandName) : (task.brand_name || task.brandName)
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
{/* Thumbnail banner */}
|
||||||
|
{currentThumbnail && (
|
||||||
|
<div className="relative -mx-5 -mt-4 mb-3 h-32 overflow-hidden">
|
||||||
|
<img src={currentThumbnail} alt="" className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-white/80 to-transparent" />
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveThumbnail}
|
||||||
|
className="absolute top-2 right-2 p-1 bg-black/40 hover:bg-black/60 rounded-full text-white transition-colors"
|
||||||
|
title={t('tasks.removeThumbnail')}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.title}
|
||||||
|
onChange={e => update('title', e.target.value)}
|
||||||
|
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||||
|
placeholder={t('tasks.taskTitle')}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className={`inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full font-medium ${priority.color === 'bg-gray-400' ? 'bg-gray-100 text-gray-600' : priority.color === 'bg-amber-400' ? 'bg-amber-100 text-amber-700' : priority.color === 'bg-orange-500' ? 'bg-orange-100 text-orange-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full ${priority.color}`} />
|
||||||
|
{priorityOptions.find(p => p.value === form.priority)?.label}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${form.status === 'done' ? 'bg-emerald-100 text-emerald-700' : form.status === 'in_progress' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{statusOptions.find(s => s.value === form.status)?.label}
|
||||||
|
</span>
|
||||||
|
{isOverdue && !isCreateMode && (
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-600 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{t('tasks.overdue')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||||
|
{/* Details Section */}
|
||||||
|
<CollapsibleSection title={t('tasks.details')}>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.description')}</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => update('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||||
|
placeholder={t('posts.optionalDetails')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={form.project_id}
|
||||||
|
onChange={e => update('project_id', e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="">{t('tasks.noProject')}</option>
|
||||||
|
{(projects || []).map(p => (
|
||||||
|
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{brandName && (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
|
||||||
|
{brandName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
|
||||||
|
<select
|
||||||
|
value={form.assigned_to}
|
||||||
|
onChange={e => update('assigned_to', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="">{t('common.unassigned')}</option>
|
||||||
|
{(users || []).map(m => (
|
||||||
|
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority & Status */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
|
||||||
|
<select
|
||||||
|
value={form.priority}
|
||||||
|
onChange={e => update('priority', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
{priorityOptions.map(p => (
|
||||||
|
<option key={p.value} value={p.value}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
|
||||||
|
<select
|
||||||
|
value={form.status}
|
||||||
|
onChange={e => update('status', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
{statusOptions.map(s => (
|
||||||
|
<option key={s.value} value={s.value}>{s.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start Date & Due Date */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.startDate')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.start_date}
|
||||||
|
onChange={e => update('start_date', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.dueDate')}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.due_date}
|
||||||
|
onChange={e => update('due_date', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Created by (read-only) */}
|
||||||
|
{creatorName && !isCreateMode && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.createdBy')}</label>
|
||||||
|
<p className="text-sm text-text-secondary">{creatorName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.title || saving}
|
||||||
|
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||||
|
>
|
||||||
|
{isCreateMode ? t('tasks.createTask') : t('tasks.saveChanges')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && !isCreateMode && (
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Attachments Section */}
|
||||||
|
<CollapsibleSection
|
||||||
|
title={t('tasks.attachments')}
|
||||||
|
badge={(attachments.length + pendingFiles.length) > 0 ? (
|
||||||
|
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
|
||||||
|
{attachments.length + pendingFiles.length}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
>
|
||||||
|
<div className="px-5 pb-4">
|
||||||
|
{/* Existing attachment grid (edit mode) */}
|
||||||
|
{attachments.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
{attachments.map(att => {
|
||||||
|
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
||||||
|
const attUrl = att.url || `/api/uploads/${att.filename}`
|
||||||
|
const name = att.original_name || att.originalName || att.filename
|
||||||
|
const attId = att._id || att.id || att.Id
|
||||||
|
const isThumbnail = currentThumbnail && attUrl === currentThumbnail
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||||
|
<div className="h-20 relative">
|
||||||
|
{isImage ? (
|
||||||
|
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
|
||||||
|
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
||||||
|
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||||
|
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{isThumbnail && (
|
||||||
|
<div className="absolute top-1 left-1 p-0.5 bg-amber-400 rounded-full text-white">
|
||||||
|
<Star className="w-2.5 h-2.5 fill-current" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||||
|
{isImage && !isThumbnail && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSetThumbnail(att)}
|
||||||
|
className="p-1 bg-black/50 hover:bg-amber-500 rounded-full text-white transition-colors"
|
||||||
|
title={t('tasks.setAsThumbnail')}
|
||||||
|
>
|
||||||
|
<Star className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteAttachment(attId)}
|
||||||
|
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<X className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pending files grid (create mode) */}
|
||||||
|
{pendingFiles.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
|
{pendingFiles.map((file, i) => {
|
||||||
|
const isImage = file.type?.startsWith('image/')
|
||||||
|
const previewUrl = isImage ? URL.createObjectURL(file) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||||
|
<div className="h-20 relative">
|
||||||
|
{isImage ? (
|
||||||
|
<img src={previewUrl} alt={file.name} className="absolute inset-0 w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center gap-2 p-3">
|
||||||
|
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
|
||||||
|
<span className="text-xs text-text-secondary truncate">{file.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-1 right-1 flex items-center gap-0.5 opacity-0 group-hover/att:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||||
|
className="p-1 bg-black/50 hover:bg-red-500 rounded-full text-white transition-colors"
|
||||||
|
title={t('common.delete')}
|
||||||
|
>
|
||||||
|
<X className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload area */}
|
||||||
|
<div
|
||||||
|
onClick={() => !uploading && fileInputRef.current?.click()}
|
||||||
|
className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors ${
|
||||||
|
uploading ? 'cursor-not-allowed opacity-60 border-border' : 'cursor-pointer border-border hover:border-brand-primary/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => {
|
||||||
|
setUploadError(null)
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
const maxBytes = maxSizeMB * 1024 * 1024
|
||||||
|
const tooBig = files.find(f => f.size > maxBytes)
|
||||||
|
if (tooBig) {
|
||||||
|
setUploadError(t('tasks.fileTooLarge')
|
||||||
|
.replace('{name}', tooBig.name)
|
||||||
|
.replace('{size}', (tooBig.size / 1024 / 1024).toFixed(1))
|
||||||
|
.replace('{max}', maxSizeMB))
|
||||||
|
e.target.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isCreateMode) {
|
||||||
|
if (files.length) setPendingFiles(files)
|
||||||
|
} else {
|
||||||
|
handleFileUpload(e.target.files)
|
||||||
|
}
|
||||||
|
e.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
|
||||||
|
<p className="text-xs text-text-secondary">
|
||||||
|
{uploading ? t('posts.uploading') : t('tasks.dropOrClick')}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-text-tertiary mt-0.5">
|
||||||
|
{t('tasks.maxFileSize').replace('{size}', maxSizeMB)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{uploadError && (
|
||||||
|
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-600">
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Discussion Section (hidden in create mode) */}
|
||||||
|
{!isCreateMode && (
|
||||||
|
<CollapsibleSection title={t('tasks.discussion')} noBorder>
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<CommentsSection entityType="task" entityId={taskId} />
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
</SlidePanel>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onClose={() => setShowDeleteConfirm(false)}
|
||||||
|
title={t('tasks.deleteTask')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('tasks.deleteTask')}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{t('tasks.deleteConfirm')}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
468
client/src/components/TeamMemberPanel.jsx
Normal file
468
client/src/components/TeamMemberPanel.jsx
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { X, Trash2, ChevronDown, Check } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import SlidePanel from './SlidePanel'
|
||||||
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
|
import StatusBadge from './StatusBadge'
|
||||||
|
|
||||||
|
const ROLES = [
|
||||||
|
{ value: 'manager', label: 'Manager' },
|
||||||
|
{ value: 'approver', label: 'Approver' },
|
||||||
|
{ value: 'publisher', label: 'Publisher' },
|
||||||
|
{ value: 'content_creator', label: 'Content Creator' },
|
||||||
|
{ value: 'producer', label: 'Producer' },
|
||||||
|
{ value: 'designer', label: 'Designer' },
|
||||||
|
{ value: 'content_writer', label: 'Content Writer' },
|
||||||
|
{ value: 'social_media_manager', label: 'Social Media Manager' },
|
||||||
|
{ value: 'photographer', label: 'Photographer' },
|
||||||
|
{ value: 'videographer', label: 'Videographer' },
|
||||||
|
{ value: 'strategist', label: 'Strategist' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||||
|
const MODULE_LABELS = { marketing: 'Marketing', projects: 'Projects', finance: 'Finance' }
|
||||||
|
const MODULE_COLORS = {
|
||||||
|
marketing: { on: 'bg-emerald-100 text-emerald-700 border-emerald-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||||
|
projects: { on: 'bg-blue-100 text-blue-700 border-blue-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||||
|
finance: { on: 'bg-amber-100 text-amber-700 border-amber-300', off: 'bg-gray-100 text-gray-400 border-gray-200' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave, onDelete, canManageTeam, userRole, teams, brands: brandsList }) {
|
||||||
|
const { t, lang } = useLanguage()
|
||||||
|
const [form, setForm] = useState({})
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [showBrandsDropdown, setShowBrandsDropdown] = useState(false)
|
||||||
|
const brandsDropdownRef = useRef(null)
|
||||||
|
|
||||||
|
// Workload state (loaded internally)
|
||||||
|
const [memberTasks, setMemberTasks] = useState([])
|
||||||
|
const [memberPosts, setMemberPosts] = useState([])
|
||||||
|
const [loadingWorkload, setLoadingWorkload] = useState(false)
|
||||||
|
|
||||||
|
const memberId = member?._id || member?.id
|
||||||
|
const isCreateMode = !memberId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (member) {
|
||||||
|
setForm({
|
||||||
|
name: member.name || '',
|
||||||
|
email: member.email || '',
|
||||||
|
password: '',
|
||||||
|
role: member.team_role || member.role || 'content_writer',
|
||||||
|
brands: Array.isArray(member.brands) ? member.brands : [],
|
||||||
|
phone: member.phone || '',
|
||||||
|
modules: Array.isArray(member.modules) ? member.modules : ALL_MODULES,
|
||||||
|
team_ids: Array.isArray(member.teams) ? member.teams.map(t => t.id) : [],
|
||||||
|
})
|
||||||
|
setDirty(isCreateMode)
|
||||||
|
if (!isCreateMode) loadWorkload()
|
||||||
|
}
|
||||||
|
}, [member])
|
||||||
|
|
||||||
|
const loadWorkload = async () => {
|
||||||
|
if (!memberId) return
|
||||||
|
setLoadingWorkload(true)
|
||||||
|
try {
|
||||||
|
const [tasksRes, postsRes] = await Promise.allSettled([
|
||||||
|
api.get(`/tasks?assignedTo=${memberId}`),
|
||||||
|
api.get(`/posts?assignedTo=${memberId}`),
|
||||||
|
])
|
||||||
|
setMemberTasks(tasksRes.status === 'fulfilled' ? (tasksRes.value.data || tasksRes.value || []) : [])
|
||||||
|
setMemberPosts(postsRes.status === 'fulfilled' ? (postsRes.value.data || postsRes.value || []) : [])
|
||||||
|
} catch {
|
||||||
|
setMemberTasks([])
|
||||||
|
setMemberPosts([])
|
||||||
|
} finally {
|
||||||
|
setLoadingWorkload(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!member) return null
|
||||||
|
|
||||||
|
const update = (field, value) => {
|
||||||
|
setForm(f => ({ ...f, [field]: value }))
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close brands dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (brandsDropdownRef.current && !brandsDropdownRef.current.contains(e.target)) {
|
||||||
|
setShowBrandsDropdown(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showBrandsDropdown) document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showBrandsDropdown])
|
||||||
|
|
||||||
|
const toggleBrand = (brandName) => {
|
||||||
|
const current = form.brands || []
|
||||||
|
update('brands', current.includes(brandName)
|
||||||
|
? current.filter(b => b !== brandName)
|
||||||
|
: [...current, brandName]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(isCreateMode ? null : memberId, {
|
||||||
|
name: form.name,
|
||||||
|
email: form.email,
|
||||||
|
password: form.password,
|
||||||
|
role: form.role,
|
||||||
|
brands: form.brands || [],
|
||||||
|
phone: form.phone,
|
||||||
|
modules: form.modules,
|
||||||
|
team_ids: form.team_ids,
|
||||||
|
}, isEditingSelf)
|
||||||
|
setDirty(false)
|
||||||
|
if (isCreateMode) onClose()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
await onDelete(memberId)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = member.name?.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() || '?'
|
||||||
|
const roleName = (form.role || '').replace(/_/g, ' ')
|
||||||
|
const todoCount = memberTasks.filter(t => t.status === 'todo').length
|
||||||
|
const inProgressCount = memberTasks.filter(t => t.status === 'in_progress').length
|
||||||
|
const doneCount = memberTasks.filter(t => t.status === 'done').length
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold shrink-0">
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => update('name', e.target.value)}
|
||||||
|
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||||
|
placeholder={t('team.fullName')}
|
||||||
|
/>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-brand-primary/10 text-brand-primary capitalize">
|
||||||
|
{roleName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||||
|
{/* Details Section */}
|
||||||
|
<CollapsibleSection title={t('team.details')}>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
{!isEditingSelf && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.email')} *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => update('email', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
disabled={!isCreateMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCreateMode && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.password')}</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => update('password', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
{!form.password && (
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.teamRole')}</label>
|
||||||
|
{userRole === 'manager' && isCreateMode && !isEditingSelf ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value="Contributor"
|
||||||
|
disabled
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
value={form.role}
|
||||||
|
onChange={e => update('role', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.phone')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={e => update('phone', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder="+966 ..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={brandsDropdownRef} className="relative">
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.brands')}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBrandsDropdown(prev => !prev)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white text-left"
|
||||||
|
>
|
||||||
|
<span className={`flex-1 truncate ${(form.brands || []).length === 0 ? 'text-text-tertiary' : 'text-text-primary'}`}>
|
||||||
|
{(form.brands || []).length === 0
|
||||||
|
? t('team.selectBrands')
|
||||||
|
: (form.brands || []).join(', ')
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={`w-4 h-4 text-text-tertiary shrink-0 transition-transform ${showBrandsDropdown ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Selected brand chips */}
|
||||||
|
{(form.brands || []).length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||||
|
{(form.brands || []).map(b => (
|
||||||
|
<span
|
||||||
|
key={b}
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-brand-primary/10 text-brand-primary font-medium"
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
<button type="button" onClick={() => toggleBrand(b)} className="hover:text-red-500">
|
||||||
|
<X className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{showBrandsDropdown && (
|
||||||
|
<div className="absolute z-20 mt-1 w-full bg-white border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{brandsList && brandsList.length > 0 ? (
|
||||||
|
brandsList.map(brand => {
|
||||||
|
const name = lang === 'ar' && brand.name_ar ? brand.name_ar : brand.name
|
||||||
|
const checked = (form.brands || []).includes(name)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={brand.id || brand._id}
|
||||||
|
onClick={() => toggleBrand(name)}
|
||||||
|
className={`w-full flex items-center gap-2.5 px-3 py-2 cursor-pointer hover:bg-surface-secondary transition-colors text-left ${checked ? 'bg-brand-primary/5' : ''}`}
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
||||||
|
checked ? 'bg-brand-primary border-brand-primary' : 'border-border'
|
||||||
|
}`}>
|
||||||
|
{checked && <Check className="w-3 h-3 text-white" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-text-primary">{brand.icon ? `${brand.icon} ` : ''}{name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="px-3 py-3 text-xs text-text-tertiary text-center">{t('brands.noBrands')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modules toggle */}
|
||||||
|
{!isEditingSelf && canManageTeam && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.modules')}</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ALL_MODULES.map(mod => {
|
||||||
|
const active = (form.modules || []).includes(mod)
|
||||||
|
const colors = MODULE_COLORS[mod]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mod}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
update('modules', active
|
||||||
|
? form.modules.filter(m => m !== mod)
|
||||||
|
: [...(form.modules || []), mod]
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${active ? colors.on : colors.off}`}
|
||||||
|
>
|
||||||
|
{MODULE_LABELS[mod]}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Teams multi-select */}
|
||||||
|
{teams && teams.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.teams')}</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{teams.map(team => {
|
||||||
|
const active = (form.team_ids || []).includes(team.id || team._id)
|
||||||
|
const teamId = team.id || team._id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={teamId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
update('team_ids', active
|
||||||
|
? form.team_ids.filter(id => id !== teamId)
|
||||||
|
: [...(form.team_ids || []), teamId]
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-blue-100 text-blue-700 border-blue-300'
|
||||||
|
: 'bg-gray-100 text-gray-400 border-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.name || (!isEditingSelf && isCreateMode && !form.email) || saving}
|
||||||
|
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||||
|
>
|
||||||
|
{isEditingSelf ? t('team.saveProfile') : (isCreateMode ? t('team.addMember') : t('team.saveChanges'))}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isCreateMode && !isEditingSelf && canManageTeam && onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title={t('team.remove')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
{/* Workload Section (hidden in create mode) */}
|
||||||
|
{!isCreateMode && (
|
||||||
|
<CollapsibleSection title={t('team.workload')} noBorder>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
|
<p className="text-lg font-bold text-text-primary">{memberTasks.length}</p>
|
||||||
|
<p className="text-[10px] text-text-tertiary">{t('team.totalTasks')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
|
<p className="text-lg font-bold text-amber-500">{todoCount}</p>
|
||||||
|
<p className="text-[10px] text-text-tertiary">{t('team.toDo')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
|
<p className="text-lg font-bold text-blue-500">{inProgressCount}</p>
|
||||||
|
<p className="text-[10px] text-text-tertiary">{t('team.inProgress')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-2 text-center">
|
||||||
|
<p className="text-lg font-bold text-emerald-500">{doneCount}</p>
|
||||||
|
<p className="text-[10px] text-text-tertiary">{t('tasks.done')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent tasks */}
|
||||||
|
{memberTasks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentTasks')}</h4>
|
||||||
|
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||||
|
{memberTasks.slice(0, 8).map(task => (
|
||||||
|
<div key={task._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
|
||||||
|
<span className={`text-xs flex-1 min-w-0 truncate ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={task.status} size="xs" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent posts */}
|
||||||
|
{memberPosts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-text-tertiary mb-2">{t('team.recentPosts')}</h4>
|
||||||
|
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||||
|
{memberPosts.slice(0, 8).map(post => (
|
||||||
|
<div key={post._id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-surface-secondary">
|
||||||
|
<span className="text-xs text-text-primary flex-1 min-w-0 truncate">{post.title}</span>
|
||||||
|
<StatusBadge status={post.status} size="xs" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingWorkload && (
|
||||||
|
<p className="text-xs text-text-tertiary text-center py-2">{t('common.loading')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
)}
|
||||||
|
</SlidePanel>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onClose={() => setShowDeleteConfirm(false)}
|
||||||
|
title={t('team.removeMember')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('team.remove')}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{t('team.removeConfirm', { name: member?.name })}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
client/src/components/TeamPanel.jsx
Normal file
199
client/src/components/TeamPanel.jsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { X, Trash2, Search } from 'lucide-react'
|
||||||
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { getInitials } from '../utils/api'
|
||||||
|
import Modal from './Modal'
|
||||||
|
import SlidePanel from './SlidePanel'
|
||||||
|
import CollapsibleSection from './CollapsibleSection'
|
||||||
|
|
||||||
|
export default function TeamPanel({ team, onClose, onSave, onDelete, teamMembers }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const [form, setForm] = useState({ name: '', description: '', member_ids: [] })
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const [memberSearch, setMemberSearch] = useState('')
|
||||||
|
|
||||||
|
const teamId = team?.id || team?._id
|
||||||
|
const isCreateMode = !teamId
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (team) {
|
||||||
|
setForm({
|
||||||
|
name: team.name || '',
|
||||||
|
description: team.description || '',
|
||||||
|
member_ids: team.member_ids || [],
|
||||||
|
})
|
||||||
|
setDirty(isCreateMode)
|
||||||
|
}
|
||||||
|
}, [team])
|
||||||
|
|
||||||
|
if (!team) return null
|
||||||
|
|
||||||
|
const update = (field, value) => {
|
||||||
|
setForm(f => ({ ...f, [field]: value }))
|
||||||
|
setDirty(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMember = (userId) => {
|
||||||
|
const ids = form.member_ids || []
|
||||||
|
update('member_ids', ids.includes(userId)
|
||||||
|
? ids.filter(id => id !== userId)
|
||||||
|
: [...ids, userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await onSave(isCreateMode ? null : teamId, {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
member_ids: form.member_ids,
|
||||||
|
})
|
||||||
|
setDirty(false)
|
||||||
|
if (isCreateMode) onClose()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
await onDelete(teamId)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredMembers = (teamMembers || []).filter(m =>
|
||||||
|
!memberSearch || m.name?.toLowerCase().includes(memberSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => update('name', e.target.value)}
|
||||||
|
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
|
||||||
|
placeholder={t('teams.name')}
|
||||||
|
/>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full font-medium bg-blue-100 text-blue-700">
|
||||||
|
{(form.member_ids || []).length} {t('teams.members')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SlidePanel onClose={onClose} maxWidth="420px" header={header}>
|
||||||
|
<CollapsibleSection title={t('teams.details')}>
|
||||||
|
<div className="px-5 pb-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.name')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => update('name', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
placeholder={t('teams.name')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('teams.description')}</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={e => update('description', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.name || saving}
|
||||||
|
className={`flex-1 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
||||||
|
>
|
||||||
|
{isCreateMode ? t('teams.createTeam') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isCreateMode && onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title={t('teams.deleteTeam')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
|
||||||
|
<CollapsibleSection title={t('teams.members')} noBorder>
|
||||||
|
<div className="px-5 pb-4">
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={memberSearch}
|
||||||
|
onChange={e => setMemberSearch(e.target.value)}
|
||||||
|
placeholder={t('teams.selectMembers')}
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 max-h-80 overflow-y-auto">
|
||||||
|
{filteredMembers.map(m => {
|
||||||
|
const uid = m.id || m._id
|
||||||
|
const checked = (form.member_ids || []).includes(uid)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={uid}
|
||||||
|
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-surface-secondary ${checked ? 'bg-blue-50' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleMember(uid)}
|
||||||
|
className="rounded border-border text-brand-primary focus:ring-brand-primary"
|
||||||
|
/>
|
||||||
|
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center text-white text-[10px] font-bold shrink-0">
|
||||||
|
{getInitials(m.name)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-text-primary">{m.name}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filteredMembers.length === 0 && (
|
||||||
|
<p className="text-xs text-text-tertiary text-center py-4">{t('common.noResults')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleSection>
|
||||||
|
</SlidePanel>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showDeleteConfirm}
|
||||||
|
onClose={() => setShowDeleteConfirm(false)}
|
||||||
|
title={t('teams.deleteTeam')}
|
||||||
|
isConfirm
|
||||||
|
danger
|
||||||
|
confirmText={t('teams.deleteTeam')}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
>
|
||||||
|
{t('teams.deleteConfirm')}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -76,6 +76,15 @@ export function AuthProvider({ children }) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ALL_MODULES = ['marketing', 'projects', 'finance']
|
||||||
|
|
||||||
|
const hasModule = (mod) => {
|
||||||
|
if (!user) return false
|
||||||
|
if (user.role === 'superadmin') return true
|
||||||
|
const userModules = Array.isArray(user.modules) ? user.modules : ALL_MODULES
|
||||||
|
return userModules.includes(mod)
|
||||||
|
}
|
||||||
|
|
||||||
const canDeleteResource = (type, resource) => {
|
const canDeleteResource = (type, resource) => {
|
||||||
if (!permissions) return false
|
if (!permissions) return false
|
||||||
if (type === 'post') return permissions.canDeleteAnyPost || isOwner(resource) || isAssignedTo(resource)
|
if (type === 'post') return permissions.canDeleteAnyPost || isOwner(resource) || isAssignedTo(resource)
|
||||||
@@ -90,6 +99,7 @@ export function AuthProvider({ children }) {
|
|||||||
user, loading, permissions,
|
user, loading, permissions,
|
||||||
login, logout, checkAuth,
|
login, logout, checkAuth,
|
||||||
isOwner, canEditResource, canDeleteResource,
|
isOwner, canEditResource, canDeleteResource,
|
||||||
|
hasModule,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@
|
|||||||
"nav.logout": "تسجيل الخروج",
|
"nav.logout": "تسجيل الخروج",
|
||||||
"nav.brands": "العلامات التجارية",
|
"nav.brands": "العلامات التجارية",
|
||||||
"nav.collapse": "طي",
|
"nav.collapse": "طي",
|
||||||
|
|
||||||
"common.save": "حفظ",
|
"common.save": "حفظ",
|
||||||
"common.cancel": "إلغاء",
|
"common.cancel": "إلغاء",
|
||||||
"common.delete": "حذف",
|
"common.delete": "حذف",
|
||||||
@@ -33,13 +32,11 @@
|
|||||||
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
|
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
|
||||||
"common.deleteFailed": "فشل الحذف. حاول مجدداً.",
|
"common.deleteFailed": "فشل الحذف. حاول مجدداً.",
|
||||||
"common.clearFilters": "مسح الفلاتر",
|
"common.clearFilters": "مسح الفلاتر",
|
||||||
|
|
||||||
"auth.login": "تسجيل الدخول",
|
"auth.login": "تسجيل الدخول",
|
||||||
"auth.email": "البريد الإلكتروني",
|
"auth.email": "البريد الإلكتروني",
|
||||||
"auth.password": "كلمة المرور",
|
"auth.password": "كلمة المرور",
|
||||||
"auth.loginBtn": "دخول",
|
"auth.loginBtn": "دخول",
|
||||||
"auth.signingIn": "جاري تسجيل الدخول...",
|
"auth.signingIn": "جاري تسجيل الدخول...",
|
||||||
|
|
||||||
"dashboard.title": "لوحة التحكم",
|
"dashboard.title": "لوحة التحكم",
|
||||||
"dashboard.welcomeBack": "مرحباً بعودتك",
|
"dashboard.welcomeBack": "مرحباً بعودتك",
|
||||||
"dashboard.happeningToday": "إليك ما يحدث مع تسويقك اليوم.",
|
"dashboard.happeningToday": "إليك ما يحدث مع تسويقك اليوم.",
|
||||||
@@ -70,7 +67,6 @@
|
|||||||
"dashboard.upcomingDeadlines": "المواعيد النهائية القادمة",
|
"dashboard.upcomingDeadlines": "المواعيد النهائية القادمة",
|
||||||
"dashboard.noUpcomingDeadlines": "لا توجد مواعيد نهائية هذا الأسبوع. 🎉",
|
"dashboard.noUpcomingDeadlines": "لا توجد مواعيد نهائية هذا الأسبوع. 🎉",
|
||||||
"dashboard.loadingHub": "جاري تحميل المركز الرقمي...",
|
"dashboard.loadingHub": "جاري تحميل المركز الرقمي...",
|
||||||
|
|
||||||
"posts.title": "إنتاج المحتوى",
|
"posts.title": "إنتاج المحتوى",
|
||||||
"posts.newPost": "منشور جديد",
|
"posts.newPost": "منشور جديد",
|
||||||
"posts.editPost": "تعديل المنشور",
|
"posts.editPost": "تعديل المنشور",
|
||||||
@@ -126,13 +122,11 @@
|
|||||||
"posts.periodFrom": "من",
|
"posts.periodFrom": "من",
|
||||||
"posts.periodTo": "إلى",
|
"posts.periodTo": "إلى",
|
||||||
"posts.tryDifferentFilter": "جرب تعديل الفلاتر لرؤية المزيد من المنشورات.",
|
"posts.tryDifferentFilter": "جرب تعديل الفلاتر لرؤية المزيد من المنشورات.",
|
||||||
|
|
||||||
"posts.status.draft": "مسودة",
|
"posts.status.draft": "مسودة",
|
||||||
"posts.status.in_review": "قيد المراجعة",
|
"posts.status.in_review": "قيد المراجعة",
|
||||||
"posts.status.approved": "مُعتمد",
|
"posts.status.approved": "مُعتمد",
|
||||||
"posts.status.scheduled": "مجدول",
|
"posts.status.scheduled": "مجدول",
|
||||||
"posts.status.published": "منشور",
|
"posts.status.published": "منشور",
|
||||||
|
|
||||||
"tasks.title": "المهام",
|
"tasks.title": "المهام",
|
||||||
"tasks.newTask": "مهمة جديدة",
|
"tasks.newTask": "مهمة جديدة",
|
||||||
"tasks.editTask": "تعديل المهمة",
|
"tasks.editTask": "تعديل المهمة",
|
||||||
@@ -163,7 +157,6 @@
|
|||||||
"tasks.task": "مهمة",
|
"tasks.task": "مهمة",
|
||||||
"tasks.tasks": "مهام",
|
"tasks.tasks": "مهام",
|
||||||
"tasks.of": "من",
|
"tasks.of": "من",
|
||||||
|
|
||||||
"tasks.priority.low": "منخفض",
|
"tasks.priority.low": "منخفض",
|
||||||
"tasks.priority.medium": "متوسط",
|
"tasks.priority.medium": "متوسط",
|
||||||
"tasks.priority.high": "عالي",
|
"tasks.priority.high": "عالي",
|
||||||
@@ -206,12 +199,10 @@
|
|||||||
"tasks.removeThumbnail": "إزالة الصورة المصغرة",
|
"tasks.removeThumbnail": "إزالة الصورة المصغرة",
|
||||||
"tasks.thumbnail": "الصورة المصغرة",
|
"tasks.thumbnail": "الصورة المصغرة",
|
||||||
"tasks.dropOrClick": "اسحب ملفاً أو انقر للرفع",
|
"tasks.dropOrClick": "اسحب ملفاً أو انقر للرفع",
|
||||||
|
|
||||||
"projects.thumbnail": "الصورة المصغرة",
|
"projects.thumbnail": "الصورة المصغرة",
|
||||||
"projects.uploadThumbnail": "رفع صورة مصغرة",
|
"projects.uploadThumbnail": "رفع صورة مصغرة",
|
||||||
"projects.changeThumbnail": "تغيير الصورة المصغرة",
|
"projects.changeThumbnail": "تغيير الصورة المصغرة",
|
||||||
"projects.removeThumbnail": "إزالة الصورة المصغرة",
|
"projects.removeThumbnail": "إزالة الصورة المصغرة",
|
||||||
|
|
||||||
"team.title": "الفريق",
|
"team.title": "الفريق",
|
||||||
"team.members": "أعضاء الفريق",
|
"team.members": "أعضاء الفريق",
|
||||||
"team.addMember": "إضافة عضو",
|
"team.addMember": "إضافة عضو",
|
||||||
@@ -243,15 +234,12 @@
|
|||||||
"team.noTasks": "لا توجد مهام",
|
"team.noTasks": "لا توجد مهام",
|
||||||
"team.toDo": "للتنفيذ",
|
"team.toDo": "للتنفيذ",
|
||||||
"team.inProgress": "قيد التنفيذ",
|
"team.inProgress": "قيد التنفيذ",
|
||||||
|
|
||||||
"campaigns.title": "الحملات",
|
"campaigns.title": "الحملات",
|
||||||
"campaigns.newCampaign": "حملة جديدة",
|
"campaigns.newCampaign": "حملة جديدة",
|
||||||
"campaigns.noCampaigns": "لا توجد حملات",
|
"campaigns.noCampaigns": "لا توجد حملات",
|
||||||
|
|
||||||
"assets.title": "الأصول",
|
"assets.title": "الأصول",
|
||||||
"assets.upload": "رفع",
|
"assets.upload": "رفع",
|
||||||
"assets.noAssets": "لا توجد أصول",
|
"assets.noAssets": "لا توجد أصول",
|
||||||
|
|
||||||
"brands.title": "العلامات التجارية",
|
"brands.title": "العلامات التجارية",
|
||||||
"brands.addBrand": "إضافة علامة",
|
"brands.addBrand": "إضافة علامة",
|
||||||
"brands.editBrand": "تعديل العلامة",
|
"brands.editBrand": "تعديل العلامة",
|
||||||
@@ -266,7 +254,6 @@
|
|||||||
"brands.uploadLogo": "رفع الشعار",
|
"brands.uploadLogo": "رفع الشعار",
|
||||||
"brands.changeLogo": "تغيير الشعار",
|
"brands.changeLogo": "تغيير الشعار",
|
||||||
"brands.manageBrands": "إدارة العلامات التجارية لمؤسستك",
|
"brands.manageBrands": "إدارة العلامات التجارية لمؤسستك",
|
||||||
|
|
||||||
"settings.title": "الإعدادات",
|
"settings.title": "الإعدادات",
|
||||||
"settings.language": "اللغة",
|
"settings.language": "اللغة",
|
||||||
"settings.english": "English",
|
"settings.english": "English",
|
||||||
@@ -294,7 +281,6 @@
|
|||||||
"settings.currency": "العملة",
|
"settings.currency": "العملة",
|
||||||
"settings.currencyHint": "ستُستخدم هذه العملة في جميع الصفحات المالية.",
|
"settings.currencyHint": "ستُستخدم هذه العملة في جميع الصفحات المالية.",
|
||||||
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",
|
"settings.preferences": "إدارة تفضيلاتك وإعدادات التطبيق",
|
||||||
|
|
||||||
"tutorial.skip": "تخطي",
|
"tutorial.skip": "تخطي",
|
||||||
"tutorial.next": "التالي",
|
"tutorial.next": "التالي",
|
||||||
"tutorial.prev": "السابق",
|
"tutorial.prev": "السابق",
|
||||||
@@ -317,12 +303,10 @@
|
|||||||
"tutorial.newPost.desc": "ابدأ إنشاء المحتوى من هنا. اختر علامتك التجارية والمنصات وأسنده لعضو فريق.",
|
"tutorial.newPost.desc": "ابدأ إنشاء المحتوى من هنا. اختر علامتك التجارية والمنصات وأسنده لعضو فريق.",
|
||||||
"tutorial.filters.title": "التصفية والتركيز",
|
"tutorial.filters.title": "التصفية والتركيز",
|
||||||
"tutorial.filters.desc": "استخدم الفلاتر للتركيز على علامات أو منصات أو أعضاء فريق محددين.",
|
"tutorial.filters.desc": "استخدم الفلاتر للتركيز على علامات أو منصات أو أعضاء فريق محددين.",
|
||||||
|
|
||||||
"login.title": "المركز الرقمي",
|
"login.title": "المركز الرقمي",
|
||||||
"login.subtitle": "سجل دخولك للمتابعة",
|
"login.subtitle": "سجل دخولك للمتابعة",
|
||||||
"login.forgotPassword": "نسيت كلمة المرور؟",
|
"login.forgotPassword": "نسيت كلمة المرور؟",
|
||||||
"login.defaultCreds": "بيانات الدخول الافتراضية:",
|
"login.defaultCreds": "بيانات الدخول الافتراضية:",
|
||||||
|
|
||||||
"comments.title": "النقاش",
|
"comments.title": "النقاش",
|
||||||
"comments.noComments": "لا توجد تعليقات بعد. ابدأ المحادثة.",
|
"comments.noComments": "لا توجد تعليقات بعد. ابدأ المحادثة.",
|
||||||
"comments.placeholder": "اكتب تعليقاً...",
|
"comments.placeholder": "اكتب تعليقاً...",
|
||||||
@@ -330,12 +314,10 @@
|
|||||||
"comments.minutesAgo": "منذ {n} دقيقة",
|
"comments.minutesAgo": "منذ {n} دقيقة",
|
||||||
"comments.hoursAgo": "منذ {n} ساعة",
|
"comments.hoursAgo": "منذ {n} ساعة",
|
||||||
"comments.daysAgo": "منذ {n} يوم",
|
"comments.daysAgo": "منذ {n} يوم",
|
||||||
|
|
||||||
"profile.completeYourProfile": "أكمل ملفك الشخصي",
|
"profile.completeYourProfile": "أكمل ملفك الشخصي",
|
||||||
"profile.completeDesc": "يرجى إكمال ملفك الشخصي للوصول إلى جميع الميزات ومساعدة فريقك في العثور عليك.",
|
"profile.completeDesc": "يرجى إكمال ملفك الشخصي للوصول إلى جميع الميزات ومساعدة فريقك في العثور عليك.",
|
||||||
"profile.completeProfileBtn": "إكمال الملف",
|
"profile.completeProfileBtn": "إكمال الملف",
|
||||||
"profile.later": "لاحقاً",
|
"profile.later": "لاحقاً",
|
||||||
|
|
||||||
"timeline.title": "الجدول الزمني",
|
"timeline.title": "الجدول الزمني",
|
||||||
"timeline.day": "يوم",
|
"timeline.day": "يوم",
|
||||||
"timeline.week": "أسبوع",
|
"timeline.week": "أسبوع",
|
||||||
@@ -347,11 +329,9 @@
|
|||||||
"timeline.addItems": "أضف عناصر بتواريخ لعرض الجدول الزمني",
|
"timeline.addItems": "أضف عناصر بتواريخ لعرض الجدول الزمني",
|
||||||
"timeline.tracks": "المسارات",
|
"timeline.tracks": "المسارات",
|
||||||
"timeline.timeline": "الجدول الزمني",
|
"timeline.timeline": "الجدول الزمني",
|
||||||
|
|
||||||
"posts.details": "التفاصيل",
|
"posts.details": "التفاصيل",
|
||||||
"posts.platformsLinks": "المنصات والروابط",
|
"posts.platformsLinks": "المنصات والروابط",
|
||||||
"posts.discussion": "النقاش",
|
"posts.discussion": "النقاش",
|
||||||
|
|
||||||
"campaigns.details": "التفاصيل",
|
"campaigns.details": "التفاصيل",
|
||||||
"campaigns.performance": "الأداء",
|
"campaigns.performance": "الأداء",
|
||||||
"campaigns.discussion": "النقاش",
|
"campaigns.discussion": "النقاش",
|
||||||
@@ -374,7 +354,6 @@
|
|||||||
"campaigns.editCampaign": "تعديل الحملة",
|
"campaigns.editCampaign": "تعديل الحملة",
|
||||||
"campaigns.deleteCampaign": "حذف الحملة؟",
|
"campaigns.deleteCampaign": "حذف الحملة؟",
|
||||||
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
|
"campaigns.deleteConfirm": "هل أنت متأكد من حذف هذه الحملة؟ سيتم حذف جميع البيانات المرتبطة. لا يمكن التراجع.",
|
||||||
|
|
||||||
"tracks.details": "التفاصيل",
|
"tracks.details": "التفاصيل",
|
||||||
"tracks.metrics": "المقاييس",
|
"tracks.metrics": "المقاييس",
|
||||||
"tracks.trackName": "اسم المسار",
|
"tracks.trackName": "اسم المسار",
|
||||||
@@ -389,7 +368,6 @@
|
|||||||
"tracks.editTrack": "تعديل المسار",
|
"tracks.editTrack": "تعديل المسار",
|
||||||
"tracks.deleteTrack": "حذف المسار؟",
|
"tracks.deleteTrack": "حذف المسار؟",
|
||||||
"tracks.deleteConfirm": "هل أنت متأكد من حذف هذا المسار؟ لا يمكن التراجع.",
|
"tracks.deleteConfirm": "هل أنت متأكد من حذف هذا المسار؟ لا يمكن التراجع.",
|
||||||
|
|
||||||
"projects.details": "التفاصيل",
|
"projects.details": "التفاصيل",
|
||||||
"projects.discussion": "النقاش",
|
"projects.discussion": "النقاش",
|
||||||
"projects.name": "الاسم",
|
"projects.name": "الاسم",
|
||||||
@@ -402,7 +380,6 @@
|
|||||||
"projects.editProject": "تعديل المشروع",
|
"projects.editProject": "تعديل المشروع",
|
||||||
"projects.deleteProject": "حذف المشروع؟",
|
"projects.deleteProject": "حذف المشروع؟",
|
||||||
"projects.deleteConfirm": "هل أنت متأكد من حذف هذا المشروع؟ لا يمكن التراجع.",
|
"projects.deleteConfirm": "هل أنت متأكد من حذف هذا المشروع؟ لا يمكن التراجع.",
|
||||||
|
|
||||||
"team.details": "التفاصيل",
|
"team.details": "التفاصيل",
|
||||||
"team.workload": "عبء العمل",
|
"team.workload": "عبء العمل",
|
||||||
"team.recentTasks": "المهام الأخيرة",
|
"team.recentTasks": "المهام الأخيرة",
|
||||||
@@ -412,11 +389,9 @@
|
|||||||
"team.gridView": "عرض الشبكة",
|
"team.gridView": "عرض الشبكة",
|
||||||
"team.teamsView": "عرض الفرق",
|
"team.teamsView": "عرض الفرق",
|
||||||
"team.unassigned": "غير مُعيّن",
|
"team.unassigned": "غير مُعيّن",
|
||||||
|
|
||||||
"modules.marketing": "التسويق",
|
"modules.marketing": "التسويق",
|
||||||
"modules.projects": "المشاريع",
|
"modules.projects": "المشاريع",
|
||||||
"modules.finance": "المالية",
|
"modules.finance": "المالية",
|
||||||
|
|
||||||
"teams.title": "الفرق",
|
"teams.title": "الفرق",
|
||||||
"teams.teams": "الفرق",
|
"teams.teams": "الفرق",
|
||||||
"teams.createTeam": "إنشاء فريق",
|
"teams.createTeam": "إنشاء فريق",
|
||||||
@@ -429,7 +404,6 @@
|
|||||||
"teams.details": "التفاصيل",
|
"teams.details": "التفاصيل",
|
||||||
"teams.noTeams": "لا توجد فرق بعد",
|
"teams.noTeams": "لا توجد فرق بعد",
|
||||||
"teams.selectMembers": "بحث عن أعضاء...",
|
"teams.selectMembers": "بحث عن أعضاء...",
|
||||||
|
|
||||||
"dates.today": "اليوم",
|
"dates.today": "اليوم",
|
||||||
"dates.yesterday": "أمس",
|
"dates.yesterday": "أمس",
|
||||||
"dates.thisWeek": "هذا الأسبوع",
|
"dates.thisWeek": "هذا الأسبوع",
|
||||||
@@ -440,16 +414,13 @@
|
|||||||
"dates.thisYear": "هذا العام",
|
"dates.thisYear": "هذا العام",
|
||||||
"dates.customRange": "نطاق مخصص",
|
"dates.customRange": "نطاق مخصص",
|
||||||
"dates.clearDates": "مسح التواريخ",
|
"dates.clearDates": "مسح التواريخ",
|
||||||
|
|
||||||
"dashboard.myTasks": "مهامي",
|
"dashboard.myTasks": "مهامي",
|
||||||
"dashboard.projectProgress": "تقدم المشاريع",
|
"dashboard.projectProgress": "تقدم المشاريع",
|
||||||
"dashboard.noProjectsYet": "لا توجد مشاريع بعد",
|
"dashboard.noProjectsYet": "لا توجد مشاريع بعد",
|
||||||
|
|
||||||
"finance.project": "المشروع",
|
"finance.project": "المشروع",
|
||||||
"finance.projectBudget": "ميزانية المشروع",
|
"finance.projectBudget": "ميزانية المشروع",
|
||||||
"finance.projectBreakdown": "توزيع المشاريع",
|
"finance.projectBreakdown": "توزيع المشاريع",
|
||||||
"finance.budgetFor": "ميزانية لـ",
|
"finance.budgetFor": "ميزانية لـ",
|
||||||
|
|
||||||
"budgets.title": "الميزانيات",
|
"budgets.title": "الميزانيات",
|
||||||
"budgets.subtitle": "إضافة وإدارة سجلات الميزانية — تتبع المصدر والوجهة والتخصيص",
|
"budgets.subtitle": "إضافة وإدارة سجلات الميزانية — تتبع المصدر والوجهة والتخصيص",
|
||||||
"budgets.addEntry": "إضافة سجل",
|
"budgets.addEntry": "إضافة سجل",
|
||||||
@@ -487,7 +458,13 @@
|
|||||||
"budgets.allTypes": "الكل",
|
"budgets.allTypes": "الكل",
|
||||||
"budgets.net": "صافي",
|
"budgets.net": "صافي",
|
||||||
"budgets.dateExpensed": "التاريخ",
|
"budgets.dateExpensed": "التاريخ",
|
||||||
|
|
||||||
"dashboard.expenses": "المصروفات",
|
"dashboard.expenses": "المصروفات",
|
||||||
"finance.expenses": "إجمالي المصروفات"
|
"finance.expenses": "إجمالي المصروفات",
|
||||||
}
|
"settings.uploads": "الرفع",
|
||||||
|
"settings.maxFileSize": "الحد الأقصى لحجم الملف",
|
||||||
|
"settings.maxFileSizeHint": "الحد الأقصى المسموح لحجم المرفقات (١-٥٠٠ ميجابايت)",
|
||||||
|
"settings.mb": "ميجابايت",
|
||||||
|
"settings.saved": "تم حفظ الإعدادات!",
|
||||||
|
"tasks.maxFileSize": "الحد الأقصى: {size} ميجابايت",
|
||||||
|
"tasks.fileTooLarge": "الملف \"{name}\" كبير جداً ({size} ميجابايت). الحد المسموح: {max} ميجابايت."
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@
|
|||||||
"nav.logout": "Logout",
|
"nav.logout": "Logout",
|
||||||
"nav.brands": "Brands",
|
"nav.brands": "Brands",
|
||||||
"nav.collapse": "Collapse",
|
"nav.collapse": "Collapse",
|
||||||
|
|
||||||
"common.save": "Save",
|
"common.save": "Save",
|
||||||
"common.cancel": "Cancel",
|
"common.cancel": "Cancel",
|
||||||
"common.delete": "Delete",
|
"common.delete": "Delete",
|
||||||
@@ -33,13 +32,11 @@
|
|||||||
"common.updateFailed": "Failed to update. Please try again.",
|
"common.updateFailed": "Failed to update. Please try again.",
|
||||||
"common.deleteFailed": "Failed to delete. Please try again.",
|
"common.deleteFailed": "Failed to delete. Please try again.",
|
||||||
"common.clearFilters": "Clear Filters",
|
"common.clearFilters": "Clear Filters",
|
||||||
|
|
||||||
"auth.login": "Sign In",
|
"auth.login": "Sign In",
|
||||||
"auth.email": "Email",
|
"auth.email": "Email",
|
||||||
"auth.password": "Password",
|
"auth.password": "Password",
|
||||||
"auth.loginBtn": "Sign In",
|
"auth.loginBtn": "Sign In",
|
||||||
"auth.signingIn": "Signing in...",
|
"auth.signingIn": "Signing in...",
|
||||||
|
|
||||||
"dashboard.title": "Dashboard",
|
"dashboard.title": "Dashboard",
|
||||||
"dashboard.welcomeBack": "Welcome back",
|
"dashboard.welcomeBack": "Welcome back",
|
||||||
"dashboard.happeningToday": "Here's what's happening with your marketing today.",
|
"dashboard.happeningToday": "Here's what's happening with your marketing today.",
|
||||||
@@ -70,7 +67,6 @@
|
|||||||
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
|
"dashboard.upcomingDeadlines": "Upcoming Deadlines",
|
||||||
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
|
"dashboard.noUpcomingDeadlines": "No upcoming deadlines this week. 🎉",
|
||||||
"dashboard.loadingHub": "Loading Digital Hub...",
|
"dashboard.loadingHub": "Loading Digital Hub...",
|
||||||
|
|
||||||
"posts.title": "Post Production",
|
"posts.title": "Post Production",
|
||||||
"posts.newPost": "New Post",
|
"posts.newPost": "New Post",
|
||||||
"posts.editPost": "Edit Post",
|
"posts.editPost": "Edit Post",
|
||||||
@@ -126,13 +122,11 @@
|
|||||||
"posts.periodFrom": "From",
|
"posts.periodFrom": "From",
|
||||||
"posts.periodTo": "To",
|
"posts.periodTo": "To",
|
||||||
"posts.tryDifferentFilter": "Try adjusting your filters to see more posts.",
|
"posts.tryDifferentFilter": "Try adjusting your filters to see more posts.",
|
||||||
|
|
||||||
"posts.status.draft": "Draft",
|
"posts.status.draft": "Draft",
|
||||||
"posts.status.in_review": "In Review",
|
"posts.status.in_review": "In Review",
|
||||||
"posts.status.approved": "Approved",
|
"posts.status.approved": "Approved",
|
||||||
"posts.status.scheduled": "Scheduled",
|
"posts.status.scheduled": "Scheduled",
|
||||||
"posts.status.published": "Published",
|
"posts.status.published": "Published",
|
||||||
|
|
||||||
"tasks.title": "Tasks",
|
"tasks.title": "Tasks",
|
||||||
"tasks.newTask": "New Task",
|
"tasks.newTask": "New Task",
|
||||||
"tasks.editTask": "Edit Task",
|
"tasks.editTask": "Edit Task",
|
||||||
@@ -163,7 +157,6 @@
|
|||||||
"tasks.task": "task",
|
"tasks.task": "task",
|
||||||
"tasks.tasks": "tasks",
|
"tasks.tasks": "tasks",
|
||||||
"tasks.of": "of",
|
"tasks.of": "of",
|
||||||
|
|
||||||
"tasks.priority.low": "Low",
|
"tasks.priority.low": "Low",
|
||||||
"tasks.priority.medium": "Medium",
|
"tasks.priority.medium": "Medium",
|
||||||
"tasks.priority.high": "High",
|
"tasks.priority.high": "High",
|
||||||
@@ -206,12 +199,10 @@
|
|||||||
"tasks.removeThumbnail": "Remove thumbnail",
|
"tasks.removeThumbnail": "Remove thumbnail",
|
||||||
"tasks.thumbnail": "Thumbnail",
|
"tasks.thumbnail": "Thumbnail",
|
||||||
"tasks.dropOrClick": "Drop file or click to upload",
|
"tasks.dropOrClick": "Drop file or click to upload",
|
||||||
|
|
||||||
"projects.thumbnail": "Thumbnail",
|
"projects.thumbnail": "Thumbnail",
|
||||||
"projects.uploadThumbnail": "Upload Thumbnail",
|
"projects.uploadThumbnail": "Upload Thumbnail",
|
||||||
"projects.changeThumbnail": "Change Thumbnail",
|
"projects.changeThumbnail": "Change Thumbnail",
|
||||||
"projects.removeThumbnail": "Remove Thumbnail",
|
"projects.removeThumbnail": "Remove Thumbnail",
|
||||||
|
|
||||||
"team.title": "Team",
|
"team.title": "Team",
|
||||||
"team.members": "Team Members",
|
"team.members": "Team Members",
|
||||||
"team.addMember": "Add Member",
|
"team.addMember": "Add Member",
|
||||||
@@ -243,15 +234,12 @@
|
|||||||
"team.noTasks": "No tasks",
|
"team.noTasks": "No tasks",
|
||||||
"team.toDo": "To Do",
|
"team.toDo": "To Do",
|
||||||
"team.inProgress": "In Progress",
|
"team.inProgress": "In Progress",
|
||||||
|
|
||||||
"campaigns.title": "Campaigns",
|
"campaigns.title": "Campaigns",
|
||||||
"campaigns.newCampaign": "New Campaign",
|
"campaigns.newCampaign": "New Campaign",
|
||||||
"campaigns.noCampaigns": "No campaigns",
|
"campaigns.noCampaigns": "No campaigns",
|
||||||
|
|
||||||
"assets.title": "Assets",
|
"assets.title": "Assets",
|
||||||
"assets.upload": "Upload",
|
"assets.upload": "Upload",
|
||||||
"assets.noAssets": "No assets",
|
"assets.noAssets": "No assets",
|
||||||
|
|
||||||
"brands.title": "Brands",
|
"brands.title": "Brands",
|
||||||
"brands.addBrand": "Add Brand",
|
"brands.addBrand": "Add Brand",
|
||||||
"brands.editBrand": "Edit Brand",
|
"brands.editBrand": "Edit Brand",
|
||||||
@@ -266,7 +254,6 @@
|
|||||||
"brands.uploadLogo": "Upload Logo",
|
"brands.uploadLogo": "Upload Logo",
|
||||||
"brands.changeLogo": "Change Logo",
|
"brands.changeLogo": "Change Logo",
|
||||||
"brands.manageBrands": "Manage your organization's brands",
|
"brands.manageBrands": "Manage your organization's brands",
|
||||||
|
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
"settings.language": "Language",
|
"settings.language": "Language",
|
||||||
"settings.english": "English",
|
"settings.english": "English",
|
||||||
@@ -294,7 +281,6 @@
|
|||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.currencyHint": "This currency will be used across all financial pages.",
|
"settings.currencyHint": "This currency will be used across all financial pages.",
|
||||||
"settings.preferences": "Manage your preferences and app settings",
|
"settings.preferences": "Manage your preferences and app settings",
|
||||||
|
|
||||||
"tutorial.skip": "Skip Tutorial",
|
"tutorial.skip": "Skip Tutorial",
|
||||||
"tutorial.next": "Next",
|
"tutorial.next": "Next",
|
||||||
"tutorial.prev": "Back",
|
"tutorial.prev": "Back",
|
||||||
@@ -317,12 +303,10 @@
|
|||||||
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
|
"tutorial.newPost.desc": "Start creating content here. Pick your brand, platforms, and assign it to a team member.",
|
||||||
"tutorial.filters.title": "Filter & Focus",
|
"tutorial.filters.title": "Filter & Focus",
|
||||||
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
|
"tutorial.filters.desc": "Use filters to focus on specific brands, platforms, or team members.",
|
||||||
|
|
||||||
"login.title": "Digital Hub",
|
"login.title": "Digital Hub",
|
||||||
"login.subtitle": "Sign in to continue",
|
"login.subtitle": "Sign in to continue",
|
||||||
"login.forgotPassword": "Forgot password?",
|
"login.forgotPassword": "Forgot password?",
|
||||||
"login.defaultCreds": "Default credentials:",
|
"login.defaultCreds": "Default credentials:",
|
||||||
|
|
||||||
"comments.title": "Discussion",
|
"comments.title": "Discussion",
|
||||||
"comments.noComments": "No comments yet. Start the conversation.",
|
"comments.noComments": "No comments yet. Start the conversation.",
|
||||||
"comments.placeholder": "Write a comment...",
|
"comments.placeholder": "Write a comment...",
|
||||||
@@ -330,12 +314,10 @@
|
|||||||
"comments.minutesAgo": "{n}m ago",
|
"comments.minutesAgo": "{n}m ago",
|
||||||
"comments.hoursAgo": "{n}h ago",
|
"comments.hoursAgo": "{n}h ago",
|
||||||
"comments.daysAgo": "{n}d ago",
|
"comments.daysAgo": "{n}d ago",
|
||||||
|
|
||||||
"profile.completeYourProfile": "Complete Your Profile",
|
"profile.completeYourProfile": "Complete Your Profile",
|
||||||
"profile.completeDesc": "Please complete your profile to access all features and help your team find you.",
|
"profile.completeDesc": "Please complete your profile to access all features and help your team find you.",
|
||||||
"profile.completeProfileBtn": "Complete Profile",
|
"profile.completeProfileBtn": "Complete Profile",
|
||||||
"profile.later": "Later",
|
"profile.later": "Later",
|
||||||
|
|
||||||
"timeline.title": "Timeline",
|
"timeline.title": "Timeline",
|
||||||
"timeline.day": "Day",
|
"timeline.day": "Day",
|
||||||
"timeline.week": "Week",
|
"timeline.week": "Week",
|
||||||
@@ -347,11 +329,9 @@
|
|||||||
"timeline.addItems": "Add items with dates to see the timeline",
|
"timeline.addItems": "Add items with dates to see the timeline",
|
||||||
"timeline.tracks": "Tracks",
|
"timeline.tracks": "Tracks",
|
||||||
"timeline.timeline": "Timeline",
|
"timeline.timeline": "Timeline",
|
||||||
|
|
||||||
"posts.details": "Details",
|
"posts.details": "Details",
|
||||||
"posts.platformsLinks": "Platforms & Links",
|
"posts.platformsLinks": "Platforms & Links",
|
||||||
"posts.discussion": "Discussion",
|
"posts.discussion": "Discussion",
|
||||||
|
|
||||||
"campaigns.details": "Details",
|
"campaigns.details": "Details",
|
||||||
"campaigns.performance": "Performance",
|
"campaigns.performance": "Performance",
|
||||||
"campaigns.discussion": "Discussion",
|
"campaigns.discussion": "Discussion",
|
||||||
@@ -374,7 +354,6 @@
|
|||||||
"campaigns.editCampaign": "Edit Campaign",
|
"campaigns.editCampaign": "Edit Campaign",
|
||||||
"campaigns.deleteCampaign": "Delete Campaign?",
|
"campaigns.deleteCampaign": "Delete Campaign?",
|
||||||
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
|
"campaigns.deleteConfirm": "Are you sure you want to delete this campaign? All associated data will be removed. This action cannot be undone.",
|
||||||
|
|
||||||
"tracks.details": "Details",
|
"tracks.details": "Details",
|
||||||
"tracks.metrics": "Metrics",
|
"tracks.metrics": "Metrics",
|
||||||
"tracks.trackName": "Track Name",
|
"tracks.trackName": "Track Name",
|
||||||
@@ -389,7 +368,6 @@
|
|||||||
"tracks.editTrack": "Edit Track",
|
"tracks.editTrack": "Edit Track",
|
||||||
"tracks.deleteTrack": "Delete Track?",
|
"tracks.deleteTrack": "Delete Track?",
|
||||||
"tracks.deleteConfirm": "Are you sure you want to delete this track? This action cannot be undone.",
|
"tracks.deleteConfirm": "Are you sure you want to delete this track? This action cannot be undone.",
|
||||||
|
|
||||||
"projects.details": "Details",
|
"projects.details": "Details",
|
||||||
"projects.discussion": "Discussion",
|
"projects.discussion": "Discussion",
|
||||||
"projects.name": "Name",
|
"projects.name": "Name",
|
||||||
@@ -402,7 +380,6 @@
|
|||||||
"projects.editProject": "Edit Project",
|
"projects.editProject": "Edit Project",
|
||||||
"projects.deleteProject": "Delete Project?",
|
"projects.deleteProject": "Delete Project?",
|
||||||
"projects.deleteConfirm": "Are you sure you want to delete this project? This action cannot be undone.",
|
"projects.deleteConfirm": "Are you sure you want to delete this project? This action cannot be undone.",
|
||||||
|
|
||||||
"team.details": "Details",
|
"team.details": "Details",
|
||||||
"team.workload": "Workload",
|
"team.workload": "Workload",
|
||||||
"team.recentTasks": "Recent Tasks",
|
"team.recentTasks": "Recent Tasks",
|
||||||
@@ -412,11 +389,9 @@
|
|||||||
"team.gridView": "Grid View",
|
"team.gridView": "Grid View",
|
||||||
"team.teamsView": "Teams View",
|
"team.teamsView": "Teams View",
|
||||||
"team.unassigned": "Unassigned",
|
"team.unassigned": "Unassigned",
|
||||||
|
|
||||||
"modules.marketing": "Marketing",
|
"modules.marketing": "Marketing",
|
||||||
"modules.projects": "Projects",
|
"modules.projects": "Projects",
|
||||||
"modules.finance": "Finance",
|
"modules.finance": "Finance",
|
||||||
|
|
||||||
"teams.title": "Teams",
|
"teams.title": "Teams",
|
||||||
"teams.teams": "Teams",
|
"teams.teams": "Teams",
|
||||||
"teams.createTeam": "Create Team",
|
"teams.createTeam": "Create Team",
|
||||||
@@ -429,7 +404,6 @@
|
|||||||
"teams.details": "Details",
|
"teams.details": "Details",
|
||||||
"teams.noTeams": "No teams yet",
|
"teams.noTeams": "No teams yet",
|
||||||
"teams.selectMembers": "Search members...",
|
"teams.selectMembers": "Search members...",
|
||||||
|
|
||||||
"dates.today": "Today",
|
"dates.today": "Today",
|
||||||
"dates.yesterday": "Yesterday",
|
"dates.yesterday": "Yesterday",
|
||||||
"dates.thisWeek": "This Week",
|
"dates.thisWeek": "This Week",
|
||||||
@@ -440,16 +414,13 @@
|
|||||||
"dates.thisYear": "This Year",
|
"dates.thisYear": "This Year",
|
||||||
"dates.customRange": "Custom Range",
|
"dates.customRange": "Custom Range",
|
||||||
"dates.clearDates": "Clear Dates",
|
"dates.clearDates": "Clear Dates",
|
||||||
|
|
||||||
"dashboard.myTasks": "My Tasks",
|
"dashboard.myTasks": "My Tasks",
|
||||||
"dashboard.projectProgress": "Project Progress",
|
"dashboard.projectProgress": "Project Progress",
|
||||||
"dashboard.noProjectsYet": "No projects yet",
|
"dashboard.noProjectsYet": "No projects yet",
|
||||||
|
|
||||||
"finance.project": "Project",
|
"finance.project": "Project",
|
||||||
"finance.projectBudget": "Project Budget",
|
"finance.projectBudget": "Project Budget",
|
||||||
"finance.projectBreakdown": "Project Breakdown",
|
"finance.projectBreakdown": "Project Breakdown",
|
||||||
"finance.budgetFor": "Budget for",
|
"finance.budgetFor": "Budget for",
|
||||||
|
|
||||||
"budgets.title": "Budgets",
|
"budgets.title": "Budgets",
|
||||||
"budgets.subtitle": "Add and manage budget entries — track source, destination, and allocation",
|
"budgets.subtitle": "Add and manage budget entries — track source, destination, and allocation",
|
||||||
"budgets.addEntry": "Add Entry",
|
"budgets.addEntry": "Add Entry",
|
||||||
@@ -487,7 +458,13 @@
|
|||||||
"budgets.allTypes": "All Types",
|
"budgets.allTypes": "All Types",
|
||||||
"budgets.net": "Net",
|
"budgets.net": "Net",
|
||||||
"budgets.dateExpensed": "Date",
|
"budgets.dateExpensed": "Date",
|
||||||
|
|
||||||
"dashboard.expenses": "Expenses",
|
"dashboard.expenses": "Expenses",
|
||||||
"finance.expenses": "Total Expenses"
|
"finance.expenses": "Total Expenses",
|
||||||
}
|
"settings.uploads": "Uploads",
|
||||||
|
"settings.maxFileSize": "Maximum File Size",
|
||||||
|
"settings.maxFileSizeHint": "Maximum allowed file size for attachments (1-500 MB)",
|
||||||
|
"settings.mb": "MB",
|
||||||
|
"settings.saved": "Settings saved!",
|
||||||
|
"tasks.maxFileSize": "Max file size: {size} MB",
|
||||||
|
"tasks.fileTooLarge": "File \"{name}\" is too large ({size} MB). Maximum allowed: {max} MB."
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useState, useEffect, useContext, useRef } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink, FolderOpen } from 'lucide-react'
|
import { Plus, LayoutGrid, List, Search, X, FileText } from 'lucide-react'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PLATFORMS } from '../utils/api'
|
import { api, PLATFORMS } from '../utils/api'
|
||||||
import KanbanBoard from '../components/KanbanBoard'
|
import KanbanBoard from '../components/KanbanBoard'
|
||||||
import PostCard from '../components/PostCard'
|
import PostCard from '../components/PostCard'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import PostDetailPanel from '../components/PostDetailPanel'
|
||||||
import Modal from '../components/Modal'
|
import DatePresetPicker from '../components/DatePresetPicker'
|
||||||
import CommentsSection from '../components/CommentsSection'
|
|
||||||
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import { useToast } from '../components/ToastContainer'
|
import { useToast } from '../components/ToastContainer'
|
||||||
@@ -22,29 +21,17 @@ const EMPTY_POST = {
|
|||||||
export default function PostProduction() {
|
export default function PostProduction() {
|
||||||
const { t, lang } = useLanguage()
|
const { t, lang } = useLanguage()
|
||||||
const { teamMembers, brands } = useContext(AppContext)
|
const { teamMembers, brands } = useContext(AppContext)
|
||||||
const { canEditResource, canDeleteResource } = useAuth()
|
const { canEditResource } = useAuth()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [posts, setPosts] = useState([])
|
const [posts, setPosts] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [view, setView] = useState('kanban')
|
const [view, setView] = useState('kanban')
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [panelPost, setPanelPost] = useState(null)
|
||||||
const [editingPost, setEditingPost] = useState(null)
|
|
||||||
const [formData, setFormData] = useState(EMPTY_POST)
|
|
||||||
const [campaigns, setCampaigns] = useState([])
|
const [campaigns, setCampaigns] = useState([])
|
||||||
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
|
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [activePreset, setActivePreset] = useState('')
|
||||||
const [attachments, setAttachments] = useState([])
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [uploadProgress, setUploadProgress] = useState(0)
|
|
||||||
const [publishError, setPublishError] = useState('')
|
|
||||||
const [moveError, setMoveError] = useState('')
|
const [moveError, setMoveError] = useState('')
|
||||||
const [dragActive, setDragActive] = useState(false)
|
|
||||||
const [showAssetPicker, setShowAssetPicker] = useState(false)
|
|
||||||
const [availableAssets, setAvailableAssets] = useState([])
|
|
||||||
const [assetSearch, setAssetSearch] = useState('')
|
|
||||||
const fileInputRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPosts()
|
loadPosts()
|
||||||
@@ -62,61 +49,6 @@ export default function PostProduction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setPublishError('')
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const data = {
|
|
||||||
title: formData.title,
|
|
||||||
description: formData.description,
|
|
||||||
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
|
||||||
assigned_to: formData.assigned_to ? Number(formData.assigned_to) : null,
|
|
||||||
status: formData.status,
|
|
||||||
platforms: formData.platforms || [],
|
|
||||||
scheduled_date: formData.scheduled_date || null,
|
|
||||||
notes: formData.notes,
|
|
||||||
campaign_id: formData.campaign_id ? Number(formData.campaign_id) : null,
|
|
||||||
publication_links: formData.publication_links || [],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client-side validation: check publication links before publishing
|
|
||||||
if (data.status === 'published' && data.platforms.length > 0) {
|
|
||||||
const missingPlatforms = data.platforms.filter(platform => {
|
|
||||||
const link = (data.publication_links || []).find(l => l.platform === platform)
|
|
||||||
return !link || !link.url || !link.url.trim()
|
|
||||||
})
|
|
||||||
if (missingPlatforms.length > 0) {
|
|
||||||
const names = missingPlatforms.map(p => PLATFORMS[p]?.label || p).join(', ')
|
|
||||||
setPublishError(`${t('posts.publishMissing')} ${names}`)
|
|
||||||
setSaving(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingPost) {
|
|
||||||
await api.patch(`/posts/${editingPost._id}`, data)
|
|
||||||
toast.success(t('posts.updated'))
|
|
||||||
} else {
|
|
||||||
await api.post('/posts', data)
|
|
||||||
toast.success(t('posts.created'))
|
|
||||||
}
|
|
||||||
setShowModal(false)
|
|
||||||
setEditingPost(null)
|
|
||||||
setFormData(EMPTY_POST)
|
|
||||||
setAttachments([])
|
|
||||||
loadPosts()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Save failed:', err)
|
|
||||||
if (err.message?.includes('Cannot publish')) {
|
|
||||||
setPublishError(err.message.replace(/.*: /, ''))
|
|
||||||
} else {
|
|
||||||
toast.error(t('common.saveFailed'))
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMovePost = async (postId, newStatus) => {
|
const handleMovePost = async (postId, newStatus) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/posts/${postId}`, { status: newStatus })
|
await api.patch(`/posts/${postId}`, { status: newStatus })
|
||||||
@@ -134,123 +66,38 @@ export default function PostProduction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadAttachments = async (postId) => {
|
const handlePanelSave = async (postId, data) => {
|
||||||
try {
|
if (postId) {
|
||||||
const data = await api.get(`/posts/${postId}/attachments`)
|
await api.patch(`/posts/${postId}`, data)
|
||||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
toast.success(t('posts.updated'))
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error('Failed to load attachments:', err)
|
await api.post('/posts', data)
|
||||||
setAttachments([])
|
toast.success(t('posts.created'))
|
||||||
}
|
}
|
||||||
|
loadPosts()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFileUpload = async (files) => {
|
const handlePanelDelete = async (postId) => {
|
||||||
if (!editingPost || !files?.length) return
|
|
||||||
setUploading(true)
|
|
||||||
setUploadProgress(0)
|
|
||||||
const postId = editingPost._id || editingPost.id
|
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('file', files[i])
|
|
||||||
try {
|
|
||||||
await api.upload(`/posts/${postId}/attachments`, fd)
|
|
||||||
setUploadProgress(Math.round(((i + 1) / files.length) * 100))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Upload failed:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploading(false)
|
|
||||||
setUploadProgress(0)
|
|
||||||
loadAttachments(postId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteAttachment = async (attachmentId) => {
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/attachments/${attachmentId}`)
|
await api.delete(`/posts/${postId}`)
|
||||||
if (editingPost) loadAttachments(editingPost._id || editingPost.id)
|
toast.success(t('posts.deleted'))
|
||||||
toast.success(t('posts.attachmentDeleted'))
|
loadPosts()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Delete attachment failed:', err)
|
console.error('Delete failed:', err)
|
||||||
toast.error(t('common.deleteFailed'))
|
toast.error(t('common.deleteFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAssetPicker = async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.get('/assets')
|
|
||||||
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load assets:', err)
|
|
||||||
setAvailableAssets([])
|
|
||||||
}
|
|
||||||
setAssetSearch('')
|
|
||||||
setShowAssetPicker(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAttachAsset = async (assetId) => {
|
|
||||||
if (!editingPost) return
|
|
||||||
const postId = editingPost._id || editingPost.id
|
|
||||||
try {
|
|
||||||
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
|
|
||||||
loadAttachments(postId)
|
|
||||||
setShowAssetPicker(false)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Attach asset failed:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(true) }
|
|
||||||
const handleDragLeaveZone = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false) }
|
|
||||||
const handleDragOverZone = (e) => { e.preventDefault(); e.stopPropagation() }
|
|
||||||
const handleDropFiles = (e) => {
|
|
||||||
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
|
||||||
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatePublicationLink = (platform, url) => {
|
|
||||||
setFormData(f => {
|
|
||||||
const links = [...(f.publication_links || [])]
|
|
||||||
const idx = links.findIndex(l => l.platform === platform)
|
|
||||||
if (idx >= 0) {
|
|
||||||
links[idx] = { ...links[idx], url }
|
|
||||||
} else {
|
|
||||||
links.push({ platform, url })
|
|
||||||
}
|
|
||||||
return { ...f, publication_links: links }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEdit = (post) => {
|
const openEdit = (post) => {
|
||||||
if (!canEditResource('post', post)) {
|
if (!canEditResource('post', post)) {
|
||||||
alert('You can only edit your own posts')
|
alert('You can only edit your own posts')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setEditingPost(post)
|
setPanelPost(post)
|
||||||
setPublishError('')
|
|
||||||
setFormData({
|
|
||||||
title: post.title || '',
|
|
||||||
description: post.description || '',
|
|
||||||
brand_id: post.brandId || post.brand_id || '',
|
|
||||||
platforms: post.platforms || (post.platform ? [post.platform] : []),
|
|
||||||
status: post.status || 'draft',
|
|
||||||
assigned_to: post.assignedTo || post.assigned_to || '',
|
|
||||||
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
|
|
||||||
notes: post.notes || '',
|
|
||||||
campaign_id: post.campaignId || post.campaign_id || '',
|
|
||||||
publication_links: post.publication_links || post.publicationLinks || [],
|
|
||||||
})
|
|
||||||
loadAttachments(post._id || post.id)
|
|
||||||
setShowModal(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openNew = () => {
|
const openNew = () => {
|
||||||
setEditingPost(null)
|
setPanelPost(EMPTY_POST)
|
||||||
setFormData(EMPTY_POST)
|
|
||||||
setAttachments([])
|
|
||||||
setPublishError('')
|
|
||||||
setShowModal(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredPosts = posts.filter(p => {
|
const filteredPosts = posts.filter(p => {
|
||||||
@@ -277,7 +124,6 @@ export default function PostProduction() {
|
|||||||
<div className="space-y-4 animate-fade-in">
|
<div className="space-y-4 animate-fade-in">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{/* Search */}
|
|
||||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||||
<input
|
<input
|
||||||
@@ -289,7 +135,6 @@ export default function PostProduction() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div data-tutorial="filters" className="flex gap-3">
|
<div data-tutorial="filters" className="flex gap-3">
|
||||||
<select
|
<select
|
||||||
value={filters.brand}
|
value={filters.brand}
|
||||||
@@ -318,12 +163,17 @@ export default function PostProduction() {
|
|||||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Period filter */}
|
<DatePresetPicker
|
||||||
|
activePreset={activePreset}
|
||||||
|
onSelect={(from, to, key) => { setFilters(f => ({ ...f, periodFrom: from, periodTo: to })); setActivePreset(key) }}
|
||||||
|
onClear={() => { setFilters(f => ({ ...f, periodFrom: '', periodTo: '' })); setActivePreset('') }}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.periodFrom}
|
value={filters.periodFrom}
|
||||||
onChange={e => setFilters(f => ({ ...f, periodFrom: e.target.value }))}
|
onChange={e => { setFilters(f => ({ ...f, periodFrom: e.target.value })); setActivePreset('') }}
|
||||||
title={t('posts.periodFrom')}
|
title={t('posts.periodFrom')}
|
||||||
className="text-sm border border-border rounded-lg px-2 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
className="text-sm border border-border rounded-lg px-2 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
/>
|
/>
|
||||||
@@ -331,14 +181,13 @@ export default function PostProduction() {
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={filters.periodTo}
|
value={filters.periodTo}
|
||||||
onChange={e => setFilters(f => ({ ...f, periodTo: e.target.value }))}
|
onChange={e => { setFilters(f => ({ ...f, periodTo: e.target.value })); setActivePreset('') }}
|
||||||
title={t('posts.periodTo')}
|
title={t('posts.periodTo')}
|
||||||
className="text-sm border border-border rounded-lg px-2 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
className="text-sm border border-border rounded-lg px-2 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* View toggle */}
|
|
||||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setView('kanban')}
|
onClick={() => setView('kanban')}
|
||||||
@@ -354,7 +203,6 @@ export default function PostProduction() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* New post */}
|
|
||||||
<button
|
<button
|
||||||
data-tutorial="new-post"
|
data-tutorial="new-post"
|
||||||
onClick={openNew}
|
onClick={openNew}
|
||||||
@@ -365,7 +213,6 @@ export default function PostProduction() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Move error banner */}
|
|
||||||
{moveError && (
|
{moveError && (
|
||||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700 flex items-center justify-between">
|
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg text-sm text-amber-700 flex items-center justify-between">
|
||||||
<span>{moveError}</span>
|
<span>{moveError}</span>
|
||||||
@@ -375,7 +222,6 @@ export default function PostProduction() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{view === 'kanban' ? (
|
{view === 'kanban' ? (
|
||||||
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
|
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
|
||||||
) : (
|
) : (
|
||||||
@@ -415,409 +261,18 @@ export default function PostProduction() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Post Detail Panel */}
|
||||||
<Modal
|
{panelPost && (
|
||||||
isOpen={showModal}
|
<PostDetailPanel
|
||||||
onClose={() => { setShowModal(false); setEditingPost(null) }}
|
post={panelPost}
|
||||||
title={editingPost ? t('posts.editPost') : t('posts.createPost')}
|
onClose={() => setPanelPost(null)}
|
||||||
size="lg"
|
onSave={handlePanelSave}
|
||||||
>
|
onDelete={handlePanelDelete}
|
||||||
<div className="space-y-4">
|
brands={brands}
|
||||||
<div>
|
teamMembers={teamMembers}
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.postTitle')} *</label>
|
campaigns={campaigns}
|
||||||
<input
|
/>
|
||||||
type="text"
|
)}
|
||||||
value={formData.title}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, title: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder={t('posts.postTitlePlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.description')}</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
|
|
||||||
rows={4}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
||||||
placeholder={t('posts.postDescPlaceholder')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Campaign */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.campaign')}</label>
|
|
||||||
<select
|
|
||||||
value={formData.campaign_id}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, campaign_id: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
<option value="">{t('posts.noCampaign')}</option>
|
|
||||||
{campaigns.map(c => <option key={c._id} value={c._id}>{c.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.brand')}</label>
|
|
||||||
<select
|
|
||||||
value={formData.brand_id}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
<option value="">{t('posts.selectBrand')}</option>
|
|
||||||
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.platforms')}</label>
|
|
||||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
|
|
||||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
|
||||||
const checked = (formData.platforms || []).includes(k)
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
key={k}
|
|
||||||
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
|
|
||||||
checked
|
|
||||||
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
|
|
||||||
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {
|
|
||||||
setFormData(f => ({
|
|
||||||
...f,
|
|
||||||
platforms: checked
|
|
||||||
? f.platforms.filter(p => p !== k)
|
|
||||||
: [...(f.platforms || []), k]
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
{v.label}
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.status')}</label>
|
|
||||||
<select
|
|
||||||
value={formData.status}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, status: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
<option value="draft">{t('posts.status.draft')}</option>
|
|
||||||
<option value="in_review">{t('posts.status.in_review')}</option>
|
|
||||||
<option value="approved">{t('posts.status.approved')}</option>
|
|
||||||
<option value="scheduled">{t('posts.status.scheduled')}</option>
|
|
||||||
<option value="published">{t('posts.status.published')}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.assignTo')}</label>
|
|
||||||
<select
|
|
||||||
value={formData.assigned_to}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, assigned_to: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
<option value="">{t('common.unassigned')}</option>
|
|
||||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.scheduledDate')}</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={formData.scheduled_date}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, scheduled_date: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.notes')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.notes}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, notes: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder={t('posts.additionalNotes')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Publication Links */}
|
|
||||||
{(formData.platforms || []).length > 0 && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Link2 className="w-4 h-4" />
|
|
||||||
{t('posts.publicationLinks')}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
|
||||||
{(formData.platforms || []).map(platformKey => {
|
|
||||||
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
|
||||||
const existingLink = (formData.publication_links || []).find(l => l.platform === platformKey)
|
|
||||||
const linkUrl = existingLink?.url || ''
|
|
||||||
return (
|
|
||||||
<div key={platformKey} className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
|
|
||||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
|
|
||||||
{platformInfo.label}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={linkUrl}
|
|
||||||
onChange={e => updatePublicationLink(platformKey, e.target.value)}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
{linkUrl && (
|
|
||||||
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
|
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{formData.status === 'published' && (formData.platforms || []).some(p => {
|
|
||||||
const link = (formData.publication_links || []).find(l => l.platform === p)
|
|
||||||
return !link || !link.url?.trim()
|
|
||||||
}) && (
|
|
||||||
<p className="text-xs text-amber-600 mt-1">⚠️ {t('posts.publishRequired')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Attachments (only for existing posts) */}
|
|
||||||
{editingPost && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Paperclip className="w-4 h-4" />
|
|
||||||
{t('posts.attachments')}
|
|
||||||
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Existing attachments */}
|
|
||||||
{attachments.length > 0 && (
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-3">
|
|
||||||
{attachments.map(att => {
|
|
||||||
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
|
|
||||||
const attUrl = att.url || `/api/uploads/${att.filename}`
|
|
||||||
const name = att.original_name || att.originalName || att.filename
|
|
||||||
return (
|
|
||||||
<div key={att.id || att._id} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
|
||||||
<div className="h-24 relative">
|
|
||||||
{isImage ? (
|
|
||||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="block h-full">
|
|
||||||
<img
|
|
||||||
src={`http://localhost:3001${attUrl}`}
|
|
||||||
alt={name}
|
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
|
|
||||||
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
|
||||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteAttachment(att.id || att._id)}
|
|
||||||
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm z-10"
|
|
||||||
title={t('posts.deleteAttachment')}
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload area */}
|
|
||||||
<div
|
|
||||||
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
|
|
||||||
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
|
|
||||||
}`}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeaveZone}
|
|
||||||
onDragOver={handleDragOverZone}
|
|
||||||
onDrop={handleDropFiles}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => handleFileUpload(e.target.files)}
|
|
||||||
/>
|
|
||||||
<Upload className="w-6 h-6 text-text-tertiary mx-auto mb-1" />
|
|
||||||
<p className="text-xs text-text-secondary">
|
|
||||||
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-text-tertiary mt-0.5">{t('posts.maxSize')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Attach from Assets button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={openAssetPicker}
|
|
||||||
className="mt-2 flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
|
|
||||||
>
|
|
||||||
<FolderOpen className="w-4 h-4" />
|
|
||||||
{t('posts.attachFromAssets')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Asset picker */}
|
|
||||||
{showAssetPicker && (
|
|
||||||
<div className="mt-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
|
|
||||||
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={assetSearch}
|
|
||||||
onChange={e => setAssetSearch(e.target.value)}
|
|
||||||
placeholder={t('common.search')}
|
|
||||||
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 max-h-48 overflow-y-auto">
|
|
||||||
{availableAssets
|
|
||||||
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
|
|
||||||
.map(asset => {
|
|
||||||
const isImage = asset.mime_type?.startsWith('image/')
|
|
||||||
const assetUrl = `/api/uploads/${asset.filename}`
|
|
||||||
const name = asset.original_name || asset.filename
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={asset.id}
|
|
||||||
onClick={() => handleAttachAsset(asset.id)}
|
|
||||||
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
|
|
||||||
>
|
|
||||||
<div className="aspect-square relative">
|
|
||||||
{isImage ? (
|
|
||||||
<img src={`http://localhost:3001${assetUrl}`} alt={name} className="absolute inset-0 w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
|
|
||||||
<FileText className="w-6 h-6 text-text-tertiary" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
|
|
||||||
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload progress */}
|
|
||||||
{uploading && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
|
|
||||||
<span>{t('posts.uploading')}</span>
|
|
||||||
<span>{uploadProgress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-surface-tertiary rounded-full h-1.5">
|
|
||||||
<div
|
|
||||||
className="bg-brand-primary h-1.5 rounded-full transition-all"
|
|
||||||
style={{ width: `${uploadProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comments (only for existing posts) */}
|
|
||||||
{editingPost && (
|
|
||||||
<CommentsSection entityType="post" entityId={editingPost._id || editingPost.id} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Publish validation error */}
|
|
||||||
{publishError && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
|
||||||
{publishError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
{editingPost && canDeleteResource('post', editingPost) && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
|
||||||
>
|
|
||||||
{t('common.delete')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowModal(false); setEditingPost(null) }}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!formData.title || saving}
|
|
||||||
className={`px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${saving ? 'btn-loading' : ''}`}
|
|
||||||
>
|
|
||||||
{editingPost ? t('posts.saveChanges') : t('posts.createPost')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showDeleteConfirm}
|
|
||||||
onClose={() => setShowDeleteConfirm(false)}
|
|
||||||
title={t('posts.deletePost')}
|
|
||||||
isConfirm
|
|
||||||
danger
|
|
||||||
confirmText={t('posts.deletePost')}
|
|
||||||
onConfirm={async () => {
|
|
||||||
if (editingPost) {
|
|
||||||
try {
|
|
||||||
await api.delete(`/posts/${editingPost._id || editingPost.id}`)
|
|
||||||
toast.success(t('posts.deleted'))
|
|
||||||
setShowModal(false)
|
|
||||||
setEditingPost(null)
|
|
||||||
loadPosts()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Delete failed:', err)
|
|
||||||
toast.error(t('common.deleteFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('posts.deleteConfirm')}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Plus, Check, Trash2, Edit3, LayoutGrid, List,
|
ArrowLeft, Plus, Check, Trash2, LayoutGrid, List,
|
||||||
GanttChart, Settings, Calendar, Clock, MessageCircle, X
|
GanttChart, Settings, Calendar, Clock, MessageCircle, X,
|
||||||
|
Image as ImageIcon
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore } from 'date-fns'
|
import { format, differenceInDays, startOfDay, addDays, isAfter, isBefore } from 'date-fns'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
@@ -12,6 +13,8 @@ import StatusBadge from '../components/StatusBadge'
|
|||||||
import BrandBadge from '../components/BrandBadge'
|
import BrandBadge from '../components/BrandBadge'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
import CommentsSection from '../components/CommentsSection'
|
import CommentsSection from '../components/CommentsSection'
|
||||||
|
import ProjectEditPanel from '../components/ProjectEditPanel'
|
||||||
|
import TaskDetailPanel from '../components/TaskDetailPanel'
|
||||||
|
|
||||||
const TASK_COLUMNS = [
|
const TASK_COLUMNS = [
|
||||||
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' },
|
{ id: 'todo', label: 'To Do', color: 'bg-gray-400' },
|
||||||
@@ -30,19 +33,16 @@ export default function ProjectDetail() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [assignableUsers, setAssignableUsers] = useState([])
|
const [assignableUsers, setAssignableUsers] = useState([])
|
||||||
const [view, setView] = useState('kanban')
|
const [view, setView] = useState('kanban')
|
||||||
const [showTaskModal, setShowTaskModal] = useState(false)
|
|
||||||
const [showProjectModal, setShowProjectModal] = useState(false)
|
|
||||||
const [editingTask, setEditingTask] = useState(null)
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [taskToDelete, setTaskToDelete] = useState(null)
|
const [taskToDelete, setTaskToDelete] = useState(null)
|
||||||
const [taskForm, setTaskForm] = useState({
|
|
||||||
title: '', description: '', priority: 'medium', assigned_to: '', start_date: '', due_date: '', status: 'todo'
|
|
||||||
})
|
|
||||||
const [projectForm, setProjectForm] = useState({
|
|
||||||
name: '', description: '', brand_id: '', owner_id: '', status: 'active', start_date: '', due_date: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const [showDiscussion, setShowDiscussion] = useState(false)
|
const [showDiscussion, setShowDiscussion] = useState(false)
|
||||||
|
const [thumbnailUploading, setThumbnailUploading] = useState(false)
|
||||||
|
const thumbnailInputRef = useRef(null)
|
||||||
|
|
||||||
|
// Panel state
|
||||||
|
const [panelProject, setPanelProject] = useState(null)
|
||||||
|
const [panelTask, setPanelTask] = useState(null)
|
||||||
|
|
||||||
// Drag state for kanban
|
// Drag state for kanban
|
||||||
const [draggedTask, setDraggedTask] = useState(null)
|
const [draggedTask, setDraggedTask] = useState(null)
|
||||||
@@ -66,32 +66,6 @@ export default function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTaskSave = async () => {
|
|
||||||
try {
|
|
||||||
const data = {
|
|
||||||
title: taskForm.title,
|
|
||||||
description: taskForm.description,
|
|
||||||
priority: taskForm.priority,
|
|
||||||
assigned_to: taskForm.assigned_to ? Number(taskForm.assigned_to) : null,
|
|
||||||
start_date: taskForm.start_date || null,
|
|
||||||
due_date: taskForm.due_date || null,
|
|
||||||
status: taskForm.status,
|
|
||||||
project_id: Number(id),
|
|
||||||
}
|
|
||||||
if (editingTask) {
|
|
||||||
await api.patch(`/tasks/${editingTask._id}`, data)
|
|
||||||
} else {
|
|
||||||
await api.post('/tasks', data)
|
|
||||||
}
|
|
||||||
setShowTaskModal(false)
|
|
||||||
setEditingTask(null)
|
|
||||||
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', start_date: '', due_date: '', status: 'todo' })
|
|
||||||
loadProject()
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Task save failed:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTaskStatusChange = async (taskId, newStatus) => {
|
const handleTaskStatusChange = async (taskId, newStatus) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||||
@@ -117,55 +91,67 @@ export default function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panel handlers
|
||||||
|
const handleProjectPanelSave = async (projectId, data) => {
|
||||||
|
await api.patch(`/projects/${projectId}`, data)
|
||||||
|
loadProject()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProjectPanelDelete = async (projectId) => {
|
||||||
|
await api.delete(`/projects/${projectId}`)
|
||||||
|
navigate('/projects')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskPanelSave = async (taskId, data) => {
|
||||||
|
if (taskId) {
|
||||||
|
await api.patch(`/tasks/${taskId}`, data)
|
||||||
|
} else {
|
||||||
|
await api.post('/tasks', { ...data, project_id: Number(id) })
|
||||||
|
}
|
||||||
|
setPanelTask(null)
|
||||||
|
loadProject()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTaskPanelDelete = async (taskId) => {
|
||||||
|
await api.delete(`/tasks/${taskId}`)
|
||||||
|
setPanelTask(null)
|
||||||
|
loadProject()
|
||||||
|
}
|
||||||
|
|
||||||
const openEditTask = (task) => {
|
const openEditTask = (task) => {
|
||||||
setEditingTask(task)
|
setPanelTask(task)
|
||||||
setTaskForm({
|
|
||||||
title: task.title || '',
|
|
||||||
description: task.description || '',
|
|
||||||
priority: task.priority || 'medium',
|
|
||||||
assigned_to: task.assignedTo || task.assigned_to || '',
|
|
||||||
start_date: task.startDate || task.start_date ? new Date(task.startDate || task.start_date).toISOString().slice(0, 10) : '',
|
|
||||||
due_date: task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 10) : '',
|
|
||||||
status: task.status || 'todo',
|
|
||||||
})
|
|
||||||
setShowTaskModal(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openNewTask = () => {
|
const openNewTask = () => {
|
||||||
setEditingTask(null)
|
setPanelTask({ title: '', status: 'todo', priority: 'medium', project_id: Number(id) })
|
||||||
setTaskForm({ title: '', description: '', priority: 'medium', assigned_to: '', start_date: '', due_date: '', status: 'todo' })
|
|
||||||
setShowTaskModal(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditProject = () => {
|
const openEditProject = () => {
|
||||||
if (!project) return
|
if (!project) return
|
||||||
setProjectForm({
|
setPanelProject(project)
|
||||||
name: project.name || '',
|
|
||||||
description: project.description || '',
|
|
||||||
brand_id: project.brandId || project.brand_id || '',
|
|
||||||
owner_id: project.ownerId || project.owner_id || '',
|
|
||||||
status: project.status || 'active',
|
|
||||||
start_date: project.startDate || project.start_date ? new Date(project.startDate || project.start_date).toISOString().slice(0, 10) : '',
|
|
||||||
due_date: project.dueDate ? new Date(project.dueDate).toISOString().slice(0, 10) : '',
|
|
||||||
})
|
|
||||||
setShowProjectModal(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleProjectSave = async () => {
|
const handleThumbnailUpload = async (file) => {
|
||||||
|
if (!file) return
|
||||||
|
setThumbnailUploading(true)
|
||||||
try {
|
try {
|
||||||
await api.patch(`/projects/${id}`, {
|
const fd = new FormData()
|
||||||
name: projectForm.name,
|
fd.append('file', file)
|
||||||
description: projectForm.description,
|
await api.upload(`/projects/${id}/thumbnail`, fd)
|
||||||
brand_id: projectForm.brand_id ? Number(projectForm.brand_id) : null,
|
|
||||||
owner_id: projectForm.owner_id ? Number(projectForm.owner_id) : null,
|
|
||||||
status: projectForm.status,
|
|
||||||
start_date: projectForm.start_date || null,
|
|
||||||
due_date: projectForm.due_date || null,
|
|
||||||
})
|
|
||||||
setShowProjectModal(false)
|
|
||||||
loadProject()
|
loadProject()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Project save failed:', err)
|
console.error('Thumbnail upload failed:', err)
|
||||||
|
} finally {
|
||||||
|
setThumbnailUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleThumbnailRemove = async () => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/projects/${id}/thumbnail`)
|
||||||
|
loadProject()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Thumbnail remove failed:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +223,38 @@ export default function ProjectDetail() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Project header */}
|
{/* Project header */}
|
||||||
<div className="bg-white rounded-xl border border-border p-6">
|
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||||
|
{/* Thumbnail banner */}
|
||||||
|
{(project.thumbnail_url || project.thumbnailUrl) && (
|
||||||
|
<div className="relative w-full h-40 overflow-hidden">
|
||||||
|
<img src={project.thumbnail_url || project.thumbnailUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
||||||
|
{canEditProject && (
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => thumbnailInputRef.current?.click()}
|
||||||
|
className="px-2 py-1 text-xs bg-black/40 hover:bg-black/60 rounded text-white transition-colors"
|
||||||
|
>
|
||||||
|
Change
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleThumbnailRemove}
|
||||||
|
className="p-1 bg-black/40 hover:bg-red-500/80 rounded text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={thumbnailInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
|
||||||
|
/>
|
||||||
|
<div className="p-6">
|
||||||
<div className="flex items-start justify-between gap-4 mb-4">
|
<div className="flex items-start justify-between gap-4 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
@@ -260,6 +277,16 @@ export default function ProjectDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{canEditProject && !project.thumbnail_url && !project.thumbnailUrl && (
|
||||||
|
<button
|
||||||
|
onClick={() => thumbnailInputRef.current?.click()}
|
||||||
|
disabled={thumbnailUploading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
{thumbnailUploading ? 'Uploading...' : 'Thumbnail'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowDiscussion(prev => !prev)}
|
onClick={() => setShowDiscussion(prev => !prev)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${
|
||||||
@@ -299,7 +326,8 @@ export default function ProjectDetail() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-text-tertiary mt-1">{completedTasks} of {tasks.length} tasks completed</p>
|
<p className="text-xs text-text-tertiary mt-1">{completedTasks} of {tasks.length} tasks completed</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>{/* end p-6 wrapper */}
|
||||||
|
</div>{/* end project header card */}
|
||||||
|
|
||||||
{/* View switcher + Add Task */}
|
{/* View switcher + Add Task */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -366,7 +394,7 @@ export default function ProjectDetail() {
|
|||||||
task={task}
|
task={task}
|
||||||
canEdit={canEditResource('task', task)}
|
canEdit={canEditResource('task', task)}
|
||||||
canDelete={canDeleteResource('task', task)}
|
canDelete={canDeleteResource('task', task)}
|
||||||
onEdit={() => openEditTask(task)}
|
onClick={() => openEditTask(task)}
|
||||||
onDelete={() => handleDeleteTask(task._id)}
|
onDelete={() => handleDeleteTask(task._id)}
|
||||||
onStatusChange={handleTaskStatusChange}
|
onStatusChange={handleTaskStatusChange}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
@@ -393,26 +421,25 @@ export default function ProjectDetail() {
|
|||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Priority</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Assignee</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">Due</th>
|
||||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider w-16"></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border-light">
|
<tbody className="divide-y divide-border-light">
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<tr><td colSpan={7} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
|
<tr><td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">No tasks yet</td></tr>
|
||||||
) : (
|
) : (
|
||||||
tasks.map(task => {
|
tasks.map(task => {
|
||||||
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
const prio = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||||
const assigneeName = task.assignedName || task.assigned_name
|
const assigneeName = task.assignedName || task.assigned_name
|
||||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
||||||
return (
|
return (
|
||||||
<tr key={task._id} className="hover:bg-surface-secondary group">
|
<tr key={task._id} onClick={() => openEditTask(task)} className="hover:bg-surface-secondary cursor-pointer transition-colors">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${prio.color}`} />
|
<div className={`w-2.5 h-2.5 rounded-full ${prio.color}`} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button onClick={() => openEditTask(task)} className="text-sm font-medium text-text-primary hover:text-brand-primary text-left">
|
<span className="text-sm font-medium text-text-primary">
|
||||||
{task.title}
|
{task.title}
|
||||||
</button>
|
</span>
|
||||||
{task.description && <p className="text-xs text-text-tertiary line-clamp-1 mt-0.5">{task.description}</p>}
|
{task.description && <p className="text-xs text-text-tertiary line-clamp-1 mt-0.5">{task.description}</p>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3"><StatusBadge status={task.status} size="xs" /></td>
|
<td className="px-4 py-3"><StatusBadge status={task.status} size="xs" /></td>
|
||||||
@@ -421,20 +448,6 @@ export default function ProjectDetail() {
|
|||||||
<td className={`px-4 py-3 text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
<td className={`px-4 py-3 text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||||
{task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : '—'}
|
{task.dueDate ? format(new Date(task.dueDate), 'MMM d, yyyy') : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
{canEditResource('task', task) && (
|
|
||||||
<button onClick={() => openEditTask(task)} className="p-1 rounded hover:bg-surface-tertiary text-text-tertiary">
|
|
||||||
<Edit3 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canDeleteResource('task', task) && (
|
|
||||||
<button onClick={() => handleDeleteTask(task._id)} className="p-1 rounded hover:bg-red-50 text-red-400">
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -466,167 +479,6 @@ export default function ProjectDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── TASK MODAL ─── */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showTaskModal}
|
|
||||||
onClose={() => { setShowTaskModal(false); setEditingTask(null) }}
|
|
||||||
title={editingTask ? 'Edit Task' : 'Add Task'}
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Title *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={taskForm.title}
|
|
||||||
onChange={e => setTaskForm(f => ({ ...f, title: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Task title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
|
||||||
<textarea
|
|
||||||
value={taskForm.description}
|
|
||||||
onChange={e => setTaskForm(f => ({ ...f, description: e.target.value }))}
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
||||||
placeholder="Optional description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Priority</label>
|
|
||||||
<select value={taskForm.priority} onChange={e => setTaskForm(f => ({ ...f, priority: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
|
||||||
<option value="low">Low</option>
|
|
||||||
<option value="medium">Medium</option>
|
|
||||||
<option value="high">High</option>
|
|
||||||
<option value="urgent">Urgent</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
|
||||||
<select value={taskForm.status} onChange={e => setTaskForm(f => ({ ...f, status: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
|
||||||
<option value="todo">To Do</option>
|
|
||||||
<option value="in_progress">In Progress</option>
|
|
||||||
<option value="done">Done</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Assign To</label>
|
|
||||||
<select value={taskForm.assigned_to} onChange={e => setTaskForm(f => ({ ...f, assigned_to: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
{assignableUsers.map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
|
|
||||||
<input type="date" value={taskForm.start_date} onChange={e => setTaskForm(f => ({ ...f, start_date: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
|
||||||
<input type="date" value={taskForm.due_date} onChange={e => setTaskForm(f => ({ ...f, due_date: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
{editingTask && canDeleteResource('task', editingTask) && (
|
|
||||||
<button onClick={() => handleDeleteTask(editingTask._id)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto">
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={() => { setShowTaskModal(false); setEditingTask(null) }}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button onClick={handleTaskSave} disabled={!taskForm.title}
|
|
||||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm">
|
|
||||||
{editingTask ? 'Save Changes' : 'Add Task'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* ─── PROJECT EDIT MODAL ─── */}
|
|
||||||
<Modal
|
|
||||||
isOpen={showProjectModal}
|
|
||||||
onClose={() => setShowProjectModal(false)}
|
|
||||||
title="Edit Project"
|
|
||||||
size="md"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Name *</label>
|
|
||||||
<input type="text" value={projectForm.name} onChange={e => setProjectForm(f => ({ ...f, name: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Project name" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Description</label>
|
|
||||||
<textarea value={projectForm.description} onChange={e => setProjectForm(f => ({ ...f, description: e.target.value }))}
|
|
||||||
rows={3} className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary resize-none"
|
|
||||||
placeholder="Project description..." />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Brand</label>
|
|
||||||
<select value={projectForm.brand_id} onChange={e => setProjectForm(f => ({ ...f, brand_id: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
|
||||||
<option value="">Select brand</option>
|
|
||||||
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Status</label>
|
|
||||||
<select value={projectForm.status} onChange={e => setProjectForm(f => ({ ...f, status: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
|
||||||
<option value="active">Active</option>
|
|
||||||
<option value="paused">Paused</option>
|
|
||||||
<option value="completed">Completed</option>
|
|
||||||
<option value="cancelled">Cancelled</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Owner</label>
|
|
||||||
<select value={projectForm.owner_id} onChange={e => setProjectForm(f => ({ ...f, owner_id: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
|
|
||||||
<option value="">Unassigned</option>
|
|
||||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Start Date</label>
|
|
||||||
<input type="date" value={projectForm.start_date} onChange={e => setProjectForm(f => ({ ...f, start_date: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">Due Date</label>
|
|
||||||
<input type="date" value={projectForm.due_date} onChange={e => setProjectForm(f => ({ ...f, due_date: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
<button onClick={() => setShowProjectModal(false)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button onClick={handleProjectSave} disabled={!projectForm.name}
|
|
||||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm">
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
{/* ─── DELETE TASK CONFIRMATION ─── */}
|
{/* ─── DELETE TASK CONFIRMATION ─── */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showDeleteConfirm}
|
isOpen={showDeleteConfirm}
|
||||||
@@ -639,22 +491,48 @@ export default function ProjectDetail() {
|
|||||||
>
|
>
|
||||||
Are you sure you want to delete this task? This action cannot be undone.
|
Are you sure you want to delete this task? This action cannot be undone.
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Project Edit Panel */}
|
||||||
|
{panelProject && (
|
||||||
|
<ProjectEditPanel
|
||||||
|
project={panelProject}
|
||||||
|
onClose={() => setPanelProject(null)}
|
||||||
|
onSave={handleProjectPanelSave}
|
||||||
|
onDelete={handleProjectPanelDelete}
|
||||||
|
brands={brands}
|
||||||
|
teamMembers={teamMembers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Task Detail Panel */}
|
||||||
|
{panelTask && (
|
||||||
|
<TaskDetailPanel
|
||||||
|
task={panelTask}
|
||||||
|
onClose={() => setPanelTask(null)}
|
||||||
|
onSave={handleTaskPanelSave}
|
||||||
|
onDelete={handleTaskPanelDelete}
|
||||||
|
projects={project ? [project] : []}
|
||||||
|
users={assignableUsers}
|
||||||
|
brands={brands}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Task Kanban Card ───────────────────────────────
|
// ─── Task Kanban Card ───────────────────────────────
|
||||||
function TaskKanbanCard({ task, canEdit, canDelete, onEdit, onDelete, onStatusChange, onDragStart, onDragEnd }) {
|
function TaskKanbanCard({ task, canEdit, canDelete, onClick, onDelete, onStatusChange, onDragStart, onDragEnd }) {
|
||||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||||
const assigneeName = task.assignedName || task.assigned_name
|
const assigneeName = task.assignedName || task.assigned_name
|
||||||
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
const isOverdue = task.dueDate && new Date(task.dueDate) < new Date() && task.status !== 'done'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable={canEdit}
|
||||||
onDragStart={(e) => onDragStart(e, task)}
|
onDragStart={(e) => canEdit && onDragStart(e, task)}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
className="bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-grab active:cursor-grabbing"
|
onClick={onClick}
|
||||||
|
className={`bg-white rounded-lg border border-border p-3 group hover:border-brand-primary/30 hover:shadow-sm transition-all cursor-pointer ${canEdit ? 'active:cursor-grabbing' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
<div className={`w-2 h-2 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||||
@@ -679,20 +557,14 @@ function TaskKanbanCard({ task, canEdit, canDelete, onEdit, onDelete, onStatusCh
|
|||||||
{(canEdit || canDelete) && (
|
{(canEdit || canDelete) && (
|
||||||
<div className="flex items-center gap-1 mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-1 mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
{canEdit && task.status !== 'done' && (
|
{canEdit && task.status !== 'done' && (
|
||||||
<button onClick={() => onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done')}
|
<button onClick={(e) => { e.stopPropagation(); onStatusChange(task._id, task.status === 'todo' ? 'in_progress' : 'done') }}
|
||||||
className="text-[10px] text-brand-primary hover:bg-brand-primary/10 px-2 py-0.5 rounded-full flex items-center gap-1">
|
className="text-[10px] text-brand-primary hover:bg-brand-primary/10 px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
{task.status === 'todo' ? 'Start' : 'Complete'}
|
{task.status === 'todo' ? 'Start' : 'Complete'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canEdit && (
|
|
||||||
<button onClick={onEdit}
|
|
||||||
className="text-[10px] text-text-tertiary hover:bg-surface-tertiary px-2 py-0.5 rounded-full flex items-center gap-1">
|
|
||||||
<Edit3 className="w-3 h-3" /> Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button onClick={onDelete}
|
<button onClick={(e) => { e.stopPropagation(); onDelete() }}
|
||||||
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
|
className="text-[10px] text-red-400 hover:bg-red-50 px-2 py-0.5 rounded-full flex items-center gap-1 ml-auto">
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -823,4 +695,3 @@ function GanttView({ tasks, project, onEditTask }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export default function Projects() {
|
|||||||
status: project.status,
|
status: project.status,
|
||||||
priority: project.priority,
|
priority: project.priority,
|
||||||
assigneeName: project.ownerName || project.owner_name,
|
assigneeName: project.ownerName || project.owner_name,
|
||||||
|
thumbnailUrl: project.thumbnail_url || project.thumbnailUrl,
|
||||||
tags: [project.status, project.priority].filter(Boolean),
|
tags: [project.status, project.priority].filter(Boolean),
|
||||||
})}
|
})}
|
||||||
onDateChange={async (projectId, { startDate, endDate }) => {
|
onDateChange={async (projectId, { startDate, endDate }) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins } from 'lucide-react'
|
import { Settings as SettingsIcon, Play, CheckCircle, Languages, Coins, Upload } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { CURRENCIES } from '../i18n/LanguageContext'
|
import { CURRENCIES } from '../i18n/LanguageContext'
|
||||||
@@ -8,6 +8,28 @@ export default function Settings() {
|
|||||||
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
const { t, lang, setLang, currency, setCurrency } = useLanguage()
|
||||||
const [restarting, setRestarting] = useState(false)
|
const [restarting, setRestarting] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [maxSizeMB, setMaxSizeMB] = useState(50)
|
||||||
|
const [sizeSaving, setSizeSaving] = useState(false)
|
||||||
|
const [sizeSaved, setSizeSaved] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/settings/app').then(s => setMaxSizeMB(s.uploadMaxSizeMB || 50)).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSaveMaxSize = async () => {
|
||||||
|
setSizeSaving(true)
|
||||||
|
setSizeSaved(false)
|
||||||
|
try {
|
||||||
|
const res = await api.patch('/settings/app', { uploadMaxSizeMB: maxSizeMB })
|
||||||
|
setMaxSizeMB(res.uploadMaxSizeMB)
|
||||||
|
setSizeSaved(true)
|
||||||
|
setTimeout(() => setSizeSaved(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to save')
|
||||||
|
} finally {
|
||||||
|
setSizeSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRestartTutorial = async () => {
|
const handleRestartTutorial = async () => {
|
||||||
setRestarting(true)
|
setRestarting(true)
|
||||||
@@ -81,6 +103,44 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Uploads Section */}
|
||||||
|
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-border">
|
||||||
|
<h2 className="text-lg font-semibold text-text-primary flex items-center gap-2">
|
||||||
|
<Upload className="w-5 h-5 text-brand-primary" />
|
||||||
|
{t('settings.uploads')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||||
|
{t('settings.maxFileSize')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="500"
|
||||||
|
value={maxSizeMB}
|
||||||
|
onChange={(e) => setMaxSizeMB(Number(e.target.value))}
|
||||||
|
className="w-24 px-3 py-2.5 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-text-secondary">{t('settings.mb')}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveMaxSize}
|
||||||
|
disabled={sizeSaving}
|
||||||
|
className="px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{sizeSaved ? (
|
||||||
|
<span className="flex items-center gap-1.5"><CheckCircle className="w-4 h-4" />{t('settings.saved')}</span>
|
||||||
|
) : sizeSaving ? '...' : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-text-tertiary mt-1.5">{t('settings.maxFileSizeHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tutorial Section */}
|
{/* Tutorial Section */}
|
||||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-border">
|
<div className="px-6 py-4 border-b border-border">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
import { useState, useEffect, useContext } from 'react'
|
||||||
import { Plus, Users, ArrowLeft, User as UserIcon } from 'lucide-react'
|
import { Plus, Users, ArrowLeft, User as UserIcon, Edit2, LayoutGrid, Network } from 'lucide-react'
|
||||||
|
import { getInitials } from '../utils/api'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
@@ -7,110 +8,125 @@ import { api } from '../utils/api'
|
|||||||
import MemberCard from '../components/MemberCard'
|
import MemberCard from '../components/MemberCard'
|
||||||
import StatusBadge from '../components/StatusBadge'
|
import StatusBadge from '../components/StatusBadge'
|
||||||
import BrandBadge from '../components/BrandBadge'
|
import BrandBadge from '../components/BrandBadge'
|
||||||
import Modal from '../components/Modal'
|
import TeamMemberPanel from '../components/TeamMemberPanel'
|
||||||
|
import TeamPanel from '../components/TeamPanel'
|
||||||
const EMPTY_MEMBER = {
|
|
||||||
name: '', email: '', password: '', role: 'content_writer', brands: '', phone: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLES = [
|
|
||||||
{ value: 'manager', label: 'Manager' },
|
|
||||||
{ value: 'approver', label: 'Approver' },
|
|
||||||
{ value: 'publisher', label: 'Publisher' },
|
|
||||||
{ value: 'content_creator', label: 'Content Creator' },
|
|
||||||
{ value: 'producer', label: 'Producer' },
|
|
||||||
{ value: 'designer', label: 'Designer' },
|
|
||||||
{ value: 'content_writer', label: 'Content Writer' },
|
|
||||||
{ value: 'social_media_manager', label: 'Social Media Manager' },
|
|
||||||
{ value: 'photographer', label: 'Photographer' },
|
|
||||||
{ value: 'videographer', label: 'Videographer' },
|
|
||||||
{ value: 'strategist', label: 'Strategist' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Team() {
|
export default function Team() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const { teamMembers, loadTeam, currentUser } = useContext(AppContext)
|
const { teamMembers, loadTeam, currentUser, teams, loadTeams, brands } = useContext(AppContext)
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [panelMember, setPanelMember] = useState(null)
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [panelIsEditingSelf, setPanelIsEditingSelf] = useState(false)
|
||||||
const [editingMember, setEditingMember] = useState(null)
|
|
||||||
const [isEditingSelf, setIsEditingSelf] = useState(false)
|
|
||||||
const [formData, setFormData] = useState(EMPTY_MEMBER)
|
|
||||||
const [selectedMember, setSelectedMember] = useState(null)
|
const [selectedMember, setSelectedMember] = useState(null)
|
||||||
const [memberTasks, setMemberTasks] = useState([])
|
const [memberTasks, setMemberTasks] = useState([])
|
||||||
const [memberPosts, setMemberPosts] = useState([])
|
const [memberPosts, setMemberPosts] = useState([])
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||||
|
const [panelTeam, setPanelTeam] = useState(null)
|
||||||
|
const [teamFilter, setTeamFilter] = useState(null)
|
||||||
|
const [viewMode, setViewMode] = useState('grid') // 'grid' | 'teams'
|
||||||
|
|
||||||
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
|
const canManageTeam = user?.role === 'superadmin' || user?.role === 'manager'
|
||||||
|
|
||||||
const openNew = () => {
|
const openNew = () => {
|
||||||
setEditingMember(null)
|
setPanelMember({ role: 'content_writer' })
|
||||||
setIsEditingSelf(false)
|
setPanelIsEditingSelf(false)
|
||||||
setFormData(EMPTY_MEMBER)
|
|
||||||
setShowModal(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEdit = (member) => {
|
const openEdit = (member) => {
|
||||||
const isSelf = member._id === user?.id || member.id === user?.id
|
const isSelf = member._id === user?.id || member.id === user?.id
|
||||||
setEditingMember(member)
|
setPanelMember(member)
|
||||||
setIsEditingSelf(isSelf)
|
setPanelIsEditingSelf(isSelf)
|
||||||
setFormData({
|
|
||||||
name: member.name || '',
|
|
||||||
email: member.email || '',
|
|
||||||
password: '',
|
|
||||||
role: member.team_role || member.role || 'content_writer',
|
|
||||||
brands: Array.isArray(member.brands) ? member.brands.join(', ') : (member.brands || ''),
|
|
||||||
phone: member.phone || '',
|
|
||||||
})
|
|
||||||
setShowModal(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handlePanelSave = async (memberId, data, isEditingSelf) => {
|
||||||
try {
|
try {
|
||||||
const brands = typeof formData.brands === 'string'
|
|
||||||
? formData.brands.split(',').map(b => b.trim()).filter(Boolean)
|
|
||||||
: formData.brands
|
|
||||||
|
|
||||||
// If editing self, use self-service endpoint
|
|
||||||
if (isEditingSelf) {
|
if (isEditingSelf) {
|
||||||
const data = {
|
await api.patch('/users/me/profile', {
|
||||||
name: formData.name,
|
name: data.name,
|
||||||
team_role: formData.role,
|
team_role: data.role,
|
||||||
brands,
|
brands: data.brands,
|
||||||
phone: formData.phone,
|
phone: data.phone,
|
||||||
}
|
})
|
||||||
await api.patch('/users/me/profile', data)
|
|
||||||
} else {
|
} else {
|
||||||
// Manager/superadmin creating or editing other users
|
const payload = {
|
||||||
const data = {
|
name: data.name,
|
||||||
name: formData.name,
|
email: data.email,
|
||||||
email: formData.email,
|
team_role: data.role,
|
||||||
team_role: formData.role,
|
brands: data.brands,
|
||||||
brands,
|
phone: data.phone,
|
||||||
phone: formData.phone,
|
modules: data.modules,
|
||||||
}
|
}
|
||||||
if (formData.password) {
|
if (data.password) payload.password = data.password
|
||||||
data.password = formData.password
|
|
||||||
}
|
if (memberId) {
|
||||||
|
await api.patch(`/users/team/${memberId}`, payload)
|
||||||
if (editingMember) {
|
|
||||||
await api.patch(`/users/team/${editingMember._id}`, data)
|
|
||||||
} else {
|
} else {
|
||||||
await api.post('/users/team', data)
|
const created = await api.post('/users/team', payload)
|
||||||
|
memberId = created?.id || created?.Id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowModal(false)
|
// Sync team memberships if team_ids provided
|
||||||
setEditingMember(null)
|
if (data.team_ids !== undefined && memberId && !isEditingSelf) {
|
||||||
setIsEditingSelf(false)
|
const member = teamMembers.find(m => (m.id || m._id) === memberId)
|
||||||
setFormData(EMPTY_MEMBER)
|
const currentTeamIds = member?.teams ? member.teams.map(t => t.id) : []
|
||||||
|
const targetTeamIds = data.team_ids || []
|
||||||
|
|
||||||
|
const toAdd = targetTeamIds.filter(id => !currentTeamIds.includes(id))
|
||||||
|
const toRemove = currentTeamIds.filter(id => !targetTeamIds.includes(id))
|
||||||
|
|
||||||
|
for (const teamId of toAdd) {
|
||||||
|
await api.post(`/teams/${teamId}/members`, { user_id: memberId })
|
||||||
|
}
|
||||||
|
for (const teamId of toRemove) {
|
||||||
|
await api.delete(`/teams/${teamId}/members/${memberId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadTeam()
|
loadTeam()
|
||||||
|
loadTeams()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Save failed:', err)
|
console.error('Save failed:', err)
|
||||||
alert(err.message || 'Failed to save')
|
alert(err.message || 'Failed to save')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTeamSave = async (teamId, data) => {
|
||||||
|
try {
|
||||||
|
if (teamId) {
|
||||||
|
await api.patch(`/teams/${teamId}`, data)
|
||||||
|
} else {
|
||||||
|
await api.post('/teams', data)
|
||||||
|
}
|
||||||
|
loadTeams()
|
||||||
|
loadTeam()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Team save failed:', err)
|
||||||
|
alert(err.message || 'Failed to save team')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTeamDelete = async (teamId) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/teams/${teamId}`)
|
||||||
|
setPanelTeam(null)
|
||||||
|
if (teamFilter === teamId) setTeamFilter(null)
|
||||||
|
loadTeams()
|
||||||
|
loadTeam()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Team delete failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePanelDelete = async (memberId) => {
|
||||||
|
await api.delete(`/users/team/${memberId}`)
|
||||||
|
if (selectedMember?._id === memberId) {
|
||||||
|
setSelectedMember(null)
|
||||||
|
}
|
||||||
|
setPanelMember(null)
|
||||||
|
loadTeam()
|
||||||
|
}
|
||||||
|
|
||||||
const openMemberDetail = async (member) => {
|
const openMemberDetail = async (member) => {
|
||||||
setSelectedMember(member)
|
setSelectedMember(member)
|
||||||
setLoadingDetail(true)
|
setLoadingDetail(true)
|
||||||
@@ -243,18 +259,67 @@ export default function Team() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Team Member Panel */}
|
||||||
|
{panelMember && (
|
||||||
|
<TeamMemberPanel
|
||||||
|
member={panelMember}
|
||||||
|
isEditingSelf={panelIsEditingSelf}
|
||||||
|
onClose={() => setPanelMember(null)}
|
||||||
|
onSave={handlePanelSave}
|
||||||
|
onDelete={canManageTeam ? handlePanelDelete : null}
|
||||||
|
canManageTeam={canManageTeam}
|
||||||
|
userRole={user?.role}
|
||||||
|
teams={teams}
|
||||||
|
brands={brands}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayedMembers = teamFilter
|
||||||
|
? teamMembers.filter(m => m.teams?.some(t => t.id === teamFilter))
|
||||||
|
: teamMembers
|
||||||
|
|
||||||
|
// Members not in any team
|
||||||
|
const unassignedMembers = teamMembers.filter(m => !m.teams || m.teams.length === 0)
|
||||||
|
|
||||||
|
const avatarColors = [
|
||||||
|
'from-indigo-400 to-purple-500',
|
||||||
|
'from-pink-400 to-rose-500',
|
||||||
|
'from-emerald-400 to-teal-500',
|
||||||
|
'from-amber-400 to-orange-500',
|
||||||
|
'from-cyan-400 to-blue-500',
|
||||||
|
]
|
||||||
|
|
||||||
// Team grid
|
// Team grid
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 animate-fade-in">
|
<div className="space-y-4 animate-fade-in">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-text-secondary">
|
<div className="flex items-center gap-3">
|
||||||
{teamMembers.length} {teamMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
<p className="text-sm text-text-secondary">
|
||||||
</p>
|
{displayedMembers.length} {displayedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||||
|
</p>
|
||||||
|
{/* View toggle */}
|
||||||
|
<div className="flex items-center bg-white border border-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-2 transition-colors ${viewMode === 'grid' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
|
||||||
|
title={t('team.gridView')}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('teams')}
|
||||||
|
className={`p-2 transition-colors ${viewMode === 'teams' ? 'bg-brand-primary text-white' : 'text-text-tertiary hover:text-text-primary'}`}
|
||||||
|
title={t('team.teamsView')}
|
||||||
|
>
|
||||||
|
<Network className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{/* Edit own profile button */}
|
{/* Edit own profile button */}
|
||||||
<button
|
<button
|
||||||
@@ -267,7 +332,18 @@ export default function Team() {
|
|||||||
<UserIcon className="w-4 h-4" />
|
<UserIcon className="w-4 h-4" />
|
||||||
{t('team.myProfile')}
|
{t('team.myProfile')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Create Team button (managers and superadmins only) */}
|
||||||
|
{canManageTeam && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelTeam({})}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white border border-border text-text-primary rounded-lg text-sm font-medium hover:bg-surface-secondary transition-colors"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{t('teams.createTeam')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add member button (managers and superadmins only) */}
|
{/* Add member button (managers and superadmins only) */}
|
||||||
{canManageTeam && (
|
{canManageTeam && (
|
||||||
<button
|
<button
|
||||||
@@ -281,168 +357,209 @@ export default function Team() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Member grid */}
|
{/* Grid view: team filter pills + member cards */}
|
||||||
{teamMembers.length === 0 ? (
|
{viewMode === 'grid' && (
|
||||||
<div className="py-20 text-center">
|
<>
|
||||||
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
{/* Team filter pills */}
|
||||||
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
|
{teams.length > 0 && (
|
||||||
</div>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
) : (
|
<span className="text-xs font-medium text-text-tertiary">{t('teams.teams')}:</span>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 stagger-children">
|
<button
|
||||||
{teamMembers.map(member => (
|
onClick={() => setTeamFilter(null)}
|
||||||
<MemberCard key={member._id} member={member} onClick={openMemberDetail} />
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||||
))}
|
!teamFilter ? 'bg-brand-primary text-white border-brand-primary' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
|
||||||
</div>
|
}`}
|
||||||
|
>
|
||||||
|
{t('common.all')}
|
||||||
|
</button>
|
||||||
|
{teams.map(team => {
|
||||||
|
const tid = team.id || team._id
|
||||||
|
const active = teamFilter === tid
|
||||||
|
return (
|
||||||
|
<div key={tid} className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setTeamFilter(active ? null : tid)}
|
||||||
|
className={`text-xs px-3 py-1.5 rounded-full border font-medium transition-colors ${
|
||||||
|
active ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-text-secondary border-border hover:bg-surface-tertiary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{team.name} ({team.member_count || 0})
|
||||||
|
</button>
|
||||||
|
{canManageTeam && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelTeam(team)}
|
||||||
|
className="p-1 text-text-tertiary hover:text-text-primary rounded"
|
||||||
|
title={t('teams.editTeam')}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member grid */}
|
||||||
|
{displayedMembers.length === 0 ? (
|
||||||
|
<div className="py-20 text-center">
|
||||||
|
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||||
|
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 stagger-children">
|
||||||
|
{displayedMembers.map(member => (
|
||||||
|
<MemberCard key={member._id} member={member} onClick={openMemberDetail} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Teams (org chart) view */}
|
||||||
<Modal
|
{viewMode === 'teams' && (
|
||||||
isOpen={showModal}
|
<div className="space-y-6">
|
||||||
onClose={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
|
{teams.length === 0 && unassignedMembers.length === 0 ? (
|
||||||
title={isEditingSelf ? t('team.editProfile') : (editingMember ? t('team.editMember') : t('team.newMember'))}
|
<div className="py-20 text-center">
|
||||||
size="md"
|
<Users className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||||
>
|
<p className="text-text-secondary font-medium">{t('team.noMembers')}</p>
|
||||||
<div className="space-y-4">
|
</div>
|
||||||
<div>
|
) : (
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.name')} *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, name: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder={t('team.fullName')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isEditingSelf && (
|
|
||||||
<>
|
<>
|
||||||
<div>
|
{teams.map(team => {
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.email')} *</label>
|
const tid = team.id || team._id
|
||||||
<input
|
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
|
||||||
type="email"
|
return (
|
||||||
value={formData.email}
|
<div key={tid} className="bg-white rounded-xl border border-border overflow-hidden">
|
||||||
onChange={e => setFormData(f => ({ ...f, email: e.target.value }))}
|
{/* Team header */}
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
|
||||||
placeholder="email@example.com"
|
<div className="flex items-center gap-3">
|
||||||
disabled={editingMember}
|
<div className="w-10 h-10 rounded-lg bg-blue-600 flex items-center justify-center text-white">
|
||||||
/>
|
<Users className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-text-primary">{team.name}</h3>
|
||||||
|
<p className="text-xs text-text-tertiary">
|
||||||
|
{members.length} {members.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||||
|
{team.description && ` · ${team.description}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canManageTeam && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelTeam(team)}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!editingMember && (
|
{/* Team members */}
|
||||||
<div>
|
{members.length === 0 ? (
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.password')} {editingMember && t('team.optional')}</label>
|
<div className="py-8 text-center text-sm text-text-tertiary">{t('team.noMembers')}</div>
|
||||||
<input
|
) : (
|
||||||
type="password"
|
<div className="divide-y divide-border-light">
|
||||||
value={formData.password}
|
{members.map(member => {
|
||||||
onChange={e => setFormData(f => ({ ...f, password: e.target.value }))}
|
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
return (
|
||||||
placeholder="••••••••"
|
<div
|
||||||
/>
|
key={member._id}
|
||||||
{!formData.password && !editingMember && (
|
onClick={() => openMemberDetail(member)}
|
||||||
<p className="text-xs text-text-tertiary mt-1">{t('team.defaultPassword')}</p>
|
className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||||
)}
|
>
|
||||||
|
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-sm font-bold shrink-0`}>
|
||||||
|
{getInitials(member.name)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
||||||
|
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
|
||||||
|
</div>
|
||||||
|
{member.brands && member.brands.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 shrink-0">
|
||||||
|
{member.brands.slice(0, 3).map(b => <BrandBadge key={b} brand={b} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Unassigned members */}
|
||||||
|
{unassignedMembers.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||||
|
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
|
||||||
|
<UserIcon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-text-primary">{t('team.unassigned')}</h3>
|
||||||
|
<p className="text-xs text-text-tertiary">
|
||||||
|
{unassignedMembers.length} {unassignedMembers.length !== 1 ? t('team.membersPlural') : t('team.member')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-light">
|
||||||
|
{unassignedMembers.map(member => {
|
||||||
|
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={member._id}
|
||||||
|
onClick={() => openMemberDetail(member)}
|
||||||
|
className="flex items-center gap-4 px-5 py-3 hover:bg-surface-secondary transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className={`w-10 h-10 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-sm font-bold shrink-0`}>
|
||||||
|
{getInitials(member.name)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-text-primary">{member.name}</p>
|
||||||
|
<p className="text-xs text-text-tertiary capitalize">{(member.team_role || member.role)?.replace('_', ' ')}</p>
|
||||||
|
</div>
|
||||||
|
{member.brands && member.brands.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 shrink-0">
|
||||||
|
{member.brands.slice(0, 3).map(b => <BrandBadge key={b} brand={b} />)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.teamRole')}</label>
|
|
||||||
{user?.role === 'manager' && !editingMember && !isEditingSelf ? (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value="Contributor"
|
|
||||||
disabled
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-text-tertiary mt-1">{t('team.fixedRole')}</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<select
|
|
||||||
value={formData.role}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, role: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
>
|
|
||||||
{ROLES.map(r => <option key={r.value} value={r.value}>{r.label}</option>)}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.phone')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, phone: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="+966 ..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('team.brands')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.brands}
|
|
||||||
onChange={e => setFormData(f => ({ ...f, brands: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
|
||||||
placeholder="Brand A, Brand B"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-text-tertiary mt-1">{t('team.brandsHelp')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
|
||||||
{editingMember && !isEditingSelf && canManageTeam && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
|
||||||
>
|
|
||||||
{t('team.remove')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowModal(false); setEditingMember(null); setIsEditingSelf(false) }}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!formData.name || (!isEditingSelf && !editingMember && !formData.email)}
|
|
||||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
|
|
||||||
>
|
|
||||||
{isEditingSelf ? t('team.saveProfile') : (editingMember ? t('team.saveChanges') : t('team.addMember'))}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
)}
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Team Member Panel */}
|
||||||
<Modal
|
{panelMember && (
|
||||||
isOpen={showDeleteConfirm}
|
<TeamMemberPanel
|
||||||
onClose={() => setShowDeleteConfirm(false)}
|
member={panelMember}
|
||||||
title={t('team.removeMember')}
|
isEditingSelf={panelIsEditingSelf}
|
||||||
isConfirm
|
onClose={() => setPanelMember(null)}
|
||||||
danger
|
onSave={handlePanelSave}
|
||||||
confirmText={t('team.remove')}
|
onDelete={canManageTeam ? handlePanelDelete : null}
|
||||||
onConfirm={async () => {
|
canManageTeam={canManageTeam}
|
||||||
if (editingMember) {
|
userRole={user?.role}
|
||||||
await api.delete(`/users/team/${editingMember._id}`)
|
teams={teams}
|
||||||
setShowModal(false)
|
brands={brands}
|
||||||
setEditingMember(null)
|
/>
|
||||||
setIsEditingSelf(false)
|
)}
|
||||||
setShowDeleteConfirm(false)
|
|
||||||
if (selectedMember?._id === editingMember._id) {
|
{/* Team Panel */}
|
||||||
setSelectedMember(null)
|
{panelTeam && (
|
||||||
}
|
<TeamPanel
|
||||||
loadTeam()
|
team={panelTeam}
|
||||||
}
|
onClose={() => setPanelTeam(null)}
|
||||||
}}
|
onSave={handleTeamSave}
|
||||||
>
|
onDelete={canManageTeam ? handleTeamDelete : null}
|
||||||
{t('team.removeConfirm', { name: editingMember?.name })}
|
teamMembers={teamMembers}
|
||||||
</Modal>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const normalize = (data) => {
|
|||||||
// Map assigned_name for display
|
// Map assigned_name for display
|
||||||
if (out.assignedName && !out.assignedToName) out.assignedToName = out.assignedName;
|
if (out.assignedName && !out.assignedToName) out.assignedToName = out.assignedName;
|
||||||
// Parse JSON text fields from NocoDB (stored as LongText)
|
// Parse JSON text fields from NocoDB (stored as LongText)
|
||||||
for (const jsonField of ['platforms', 'brands', 'tags', 'publicationLinks', 'publication_links', 'goals']) {
|
for (const jsonField of ['platforms', 'brands', 'tags', 'publicationLinks', 'publication_links', 'goals', 'modules']) {
|
||||||
if (out[jsonField] && typeof out[jsonField] === 'string') {
|
if (out[jsonField] && typeof out[jsonField] === 'string') {
|
||||||
try { out[jsonField] = JSON.parse(out[jsonField]); } catch {}
|
try { out[jsonField] = JSON.parse(out[jsonField]); } catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
77
client/src/utils/datePresets.js
Normal file
77
client/src/utils/datePresets.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
startOfDay, endOfDay, subDays,
|
||||||
|
startOfWeek, endOfWeek, subWeeks,
|
||||||
|
startOfMonth, endOfMonth, subMonths,
|
||||||
|
startOfQuarter, endOfQuarter,
|
||||||
|
startOfYear, endOfYear,
|
||||||
|
format,
|
||||||
|
} from 'date-fns'
|
||||||
|
|
||||||
|
const fmt = d => format(d, 'yyyy-MM-dd')
|
||||||
|
|
||||||
|
export const DATE_PRESETS = [
|
||||||
|
{
|
||||||
|
key: 'today',
|
||||||
|
labelKey: 'dates.today',
|
||||||
|
getRange: () => {
|
||||||
|
const now = new Date()
|
||||||
|
return { from: fmt(startOfDay(now)), to: fmt(endOfDay(now)) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'yesterday',
|
||||||
|
labelKey: 'dates.yesterday',
|
||||||
|
getRange: () => {
|
||||||
|
const d = subDays(new Date(), 1)
|
||||||
|
return { from: fmt(startOfDay(d)), to: fmt(endOfDay(d)) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'thisWeek',
|
||||||
|
labelKey: 'dates.thisWeek',
|
||||||
|
getRange: () => {
|
||||||
|
const now = new Date()
|
||||||
|
return { from: fmt(startOfWeek(now, { weekStartsOn: 0 })), to: fmt(endOfWeek(now, { weekStartsOn: 0 })) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastWeek',
|
||||||
|
labelKey: 'dates.lastWeek',
|
||||||
|
getRange: () => {
|
||||||
|
const d = subWeeks(new Date(), 1)
|
||||||
|
return { from: fmt(startOfWeek(d, { weekStartsOn: 0 })), to: fmt(endOfWeek(d, { weekStartsOn: 0 })) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'thisMonth',
|
||||||
|
labelKey: 'dates.thisMonth',
|
||||||
|
getRange: () => {
|
||||||
|
const now = new Date()
|
||||||
|
return { from: fmt(startOfMonth(now)), to: fmt(endOfMonth(now)) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastMonth',
|
||||||
|
labelKey: 'dates.lastMonth',
|
||||||
|
getRange: () => {
|
||||||
|
const d = subMonths(new Date(), 1)
|
||||||
|
return { from: fmt(startOfMonth(d)), to: fmt(endOfMonth(d)) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'thisQuarter',
|
||||||
|
labelKey: 'dates.thisQuarter',
|
||||||
|
getRange: () => {
|
||||||
|
const now = new Date()
|
||||||
|
return { from: fmt(startOfQuarter(now)), to: fmt(endOfQuarter(now)) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'thisYear',
|
||||||
|
labelKey: 'dates.thisYear',
|
||||||
|
getRange: () => {
|
||||||
|
const now = new Date()
|
||||||
|
return { from: fmt(startOfYear(now)), to: fmt(endOfYear(now)) }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
1
server/node_modules/.bin/color-support
generated
vendored
1
server/node_modules/.bin/color-support
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../color-support/bin.js
|
|
||||||
1
server/node_modules/.bin/mime
generated
vendored
1
server/node_modules/.bin/mime
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../mime/cli.js
|
|
||||||
1
server/node_modules/.bin/mkdirp
generated
vendored
1
server/node_modules/.bin/mkdirp
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../mkdirp/bin/cmd.js
|
|
||||||
1
server/node_modules/.bin/node-gyp
generated
vendored
1
server/node_modules/.bin/node-gyp
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../node-gyp/bin/node-gyp.js
|
|
||||||
1
server/node_modules/.bin/node-gyp-build
generated
vendored
1
server/node_modules/.bin/node-gyp-build
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../node-gyp-build/bin.js
|
|
||||||
1
server/node_modules/.bin/node-gyp-build-optional
generated
vendored
1
server/node_modules/.bin/node-gyp-build-optional
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../node-gyp-build/optional.js
|
|
||||||
1
server/node_modules/.bin/node-gyp-build-test
generated
vendored
1
server/node_modules/.bin/node-gyp-build-test
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../node-gyp-build/build-test.js
|
|
||||||
1
server/node_modules/.bin/node-which
generated
vendored
1
server/node_modules/.bin/node-which
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../which/bin/node-which
|
|
||||||
1
server/node_modules/.bin/nopt
generated
vendored
1
server/node_modules/.bin/nopt
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../nopt/bin/nopt.js
|
|
||||||
1
server/node_modules/.bin/prebuild-install
generated
vendored
1
server/node_modules/.bin/prebuild-install
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../prebuild-install/bin.js
|
|
||||||
1
server/node_modules/.bin/rc
generated
vendored
1
server/node_modules/.bin/rc
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../rc/cli.js
|
|
||||||
1
server/node_modules/.bin/rimraf
generated
vendored
1
server/node_modules/.bin/rimraf
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../rimraf/bin.js
|
|
||||||
1
server/node_modules/.bin/semver
generated
vendored
1
server/node_modules/.bin/semver
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../semver/bin/semver.js
|
|
||||||
2640
server/node_modules/.package-lock.json
generated
vendored
2640
server/node_modules/.package-lock.json
generated
vendored
File diff suppressed because it is too large
Load Diff
10
server/node_modules/@gar/promisify/LICENSE.md
generated
vendored
10
server/node_modules/@gar/promisify/LICENSE.md
generated
vendored
@@ -1,10 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright © 2020-2022 Michael Garvin
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
65
server/node_modules/@gar/promisify/README.md
generated
vendored
65
server/node_modules/@gar/promisify/README.md
generated
vendored
@@ -1,65 +0,0 @@
|
|||||||
# @gar/promisify
|
|
||||||
|
|
||||||
### Promisify an entire object or class instance
|
|
||||||
|
|
||||||
This module leverages es6 Proxy and Reflect to promisify every function in an
|
|
||||||
object or class instance.
|
|
||||||
|
|
||||||
It assumes the callback that the function is expecting is the last
|
|
||||||
parameter, and that it is an error-first callback with only one value,
|
|
||||||
i.e. `(err, value) => ...`. This mirrors node's `util.promisify` method.
|
|
||||||
|
|
||||||
In order that you can use it as a one-stop-shop for all your promisify
|
|
||||||
needs, you can also pass it a function. That function will be
|
|
||||||
promisified as normal using node's built-in `util.promisify` method.
|
|
||||||
|
|
||||||
[node's custom promisified
|
|
||||||
functions](https://nodejs.org/api/util.html#util_custom_promisified_functions)
|
|
||||||
will also be mirrored, further allowing this to be a drop-in replacement
|
|
||||||
for the built-in `util.promisify`.
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
Promisify an entire object
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
|
|
||||||
const promisify = require('@gar/promisify')
|
|
||||||
|
|
||||||
class Foo {
|
|
||||||
constructor (attr) {
|
|
||||||
this.attr = attr
|
|
||||||
}
|
|
||||||
|
|
||||||
double (input, cb) {
|
|
||||||
cb(null, input * 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
const foo = new Foo('baz')
|
|
||||||
const promisified = promisify(foo)
|
|
||||||
|
|
||||||
console.log(promisified.attr)
|
|
||||||
console.log(await promisified.double(1024))
|
|
||||||
```
|
|
||||||
|
|
||||||
Promisify a function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
|
|
||||||
const promisify = require('@gar/promisify')
|
|
||||||
|
|
||||||
function foo (a, cb) {
|
|
||||||
if (a !== 'bad') {
|
|
||||||
return cb(null, 'ok')
|
|
||||||
}
|
|
||||||
return cb('not ok')
|
|
||||||
}
|
|
||||||
|
|
||||||
const promisified = promisify(foo)
|
|
||||||
|
|
||||||
// This will resolve to 'ok'
|
|
||||||
promisified('good')
|
|
||||||
|
|
||||||
// this will reject
|
|
||||||
promisified('bad')
|
|
||||||
```
|
|
||||||
36
server/node_modules/@gar/promisify/index.js
generated
vendored
36
server/node_modules/@gar/promisify/index.js
generated
vendored
@@ -1,36 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
|
|
||||||
const { promisify } = require('util')
|
|
||||||
|
|
||||||
const handler = {
|
|
||||||
get: function (target, prop, receiver) {
|
|
||||||
if (typeof target[prop] !== 'function') {
|
|
||||||
return target[prop]
|
|
||||||
}
|
|
||||||
if (target[prop][promisify.custom]) {
|
|
||||||
return function () {
|
|
||||||
return Reflect.get(target, prop, receiver)[promisify.custom].apply(target, arguments)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return function () {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
Reflect.get(target, prop, receiver).apply(target, [...arguments, function (err, result) {
|
|
||||||
if (err) {
|
|
||||||
return reject(err)
|
|
||||||
}
|
|
||||||
resolve(result)
|
|
||||||
}])
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = function (thingToPromisify) {
|
|
||||||
if (typeof thingToPromisify === 'function') {
|
|
||||||
return promisify(thingToPromisify)
|
|
||||||
}
|
|
||||||
if (typeof thingToPromisify === 'object') {
|
|
||||||
return new Proxy(thingToPromisify, handler)
|
|
||||||
}
|
|
||||||
throw new TypeError('Can only promisify functions or objects')
|
|
||||||
}
|
|
||||||
32
server/node_modules/@gar/promisify/package.json
generated
vendored
32
server/node_modules/@gar/promisify/package.json
generated
vendored
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@gar/promisify",
|
|
||||||
"version": "1.1.3",
|
|
||||||
"description": "Promisify an entire class or object",
|
|
||||||
"main": "index.js",
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/wraithgar/gar-promisify.git"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "standard",
|
|
||||||
"lint:fix": "standard --fix",
|
|
||||||
"test": "lab -a @hapi/code -t 100",
|
|
||||||
"posttest": "npm run lint"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"index.js"
|
|
||||||
],
|
|
||||||
"keywords": [
|
|
||||||
"promisify",
|
|
||||||
"all",
|
|
||||||
"class",
|
|
||||||
"object"
|
|
||||||
],
|
|
||||||
"author": "Gar <gar+npm@danger.computer>",
|
|
||||||
"license": "MIT",
|
|
||||||
"devDependencies": {
|
|
||||||
"@hapi/code": "^8.0.1",
|
|
||||||
"@hapi/lab": "^24.1.0",
|
|
||||||
"standard": "^16.0.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
server/node_modules/@npmcli/fs/LICENSE.md
generated
vendored
20
server/node_modules/@npmcli/fs/LICENSE.md
generated
vendored
@@ -1,20 +0,0 @@
|
|||||||
<!-- This file is automatically added by @npmcli/template-oss. Do not edit. -->
|
|
||||||
|
|
||||||
ISC License
|
|
||||||
|
|
||||||
Copyright npm, Inc.
|
|
||||||
|
|
||||||
Permission to use, copy, modify, and/or distribute this
|
|
||||||
software for any purpose with or without fee is hereby
|
|
||||||
granted, provided that the above copyright notice and this
|
|
||||||
permission notice appear in all copies.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS" AND NPM DISCLAIMS ALL
|
|
||||||
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL
|
|
||||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO
|
|
||||||
EVENT SHALL NPM BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
||||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
||||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
|
|
||||||
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
|
||||||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE
|
|
||||||
USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
||||||
60
server/node_modules/@npmcli/fs/README.md
generated
vendored
60
server/node_modules/@npmcli/fs/README.md
generated
vendored
@@ -1,60 +0,0 @@
|
|||||||
# @npmcli/fs
|
|
||||||
|
|
||||||
polyfills, and extensions, of the core `fs` module.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- all exposed functions return promises
|
|
||||||
- `fs.rm` polyfill for node versions < 14.14.0
|
|
||||||
- `fs.mkdir` polyfill adding support for the `recursive` and `force` options in node versions < 10.12.0
|
|
||||||
- `fs.copyFile` extended to accept an `owner` option
|
|
||||||
- `fs.mkdir` extended to accept an `owner` option
|
|
||||||
- `fs.mkdtemp` extended to accept an `owner` option
|
|
||||||
- `fs.writeFile` extended to accept an `owner` option
|
|
||||||
- `fs.withTempDir` added
|
|
||||||
- `fs.cp` polyfill for node < 16.7.0
|
|
||||||
|
|
||||||
## The `owner` option
|
|
||||||
|
|
||||||
The `copyFile`, `mkdir`, `mkdtemp`, `writeFile`, and `withTempDir` functions
|
|
||||||
all accept a new `owner` property in their options. It can be used in two ways:
|
|
||||||
|
|
||||||
- `{ owner: { uid: 100, gid: 100 } }` - set the `uid` and `gid` explicitly
|
|
||||||
- `{ owner: 100 }` - use one value, will set both `uid` and `gid` the same
|
|
||||||
|
|
||||||
The special string `'inherit'` may be passed instead of a number, which will
|
|
||||||
cause this module to automatically determine the correct `uid` and/or `gid`
|
|
||||||
from the nearest existing parent directory of the target.
|
|
||||||
|
|
||||||
## `fs.withTempDir(root, fn, options) -> Promise`
|
|
||||||
|
|
||||||
### Parameters
|
|
||||||
|
|
||||||
- `root`: the directory in which to create the temporary directory
|
|
||||||
- `fn`: a function that will be called with the path to the temporary directory
|
|
||||||
- `options`
|
|
||||||
- `tmpPrefix`: a prefix to be used in the generated directory name
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
The `withTempDir` function creates a temporary directory, runs the provided
|
|
||||||
function (`fn`), then removes the temporary directory and resolves or rejects
|
|
||||||
based on the result of `fn`.
|
|
||||||
|
|
||||||
```js
|
|
||||||
const fs = require('@npmcli/fs')
|
|
||||||
const os = require('os')
|
|
||||||
|
|
||||||
// this function will be called with the full path to the temporary directory
|
|
||||||
// it is called with `await` behind the scenes, so can be async if desired.
|
|
||||||
const myFunction = async (tempPath) => {
|
|
||||||
return 'done!'
|
|
||||||
}
|
|
||||||
|
|
||||||
const main = async () => {
|
|
||||||
const result = await fs.withTempDir(os.tmpdir(), myFunction)
|
|
||||||
// result === 'done!'
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
```
|
|
||||||
17
server/node_modules/@npmcli/fs/lib/common/file-url-to-path/index.js
generated
vendored
17
server/node_modules/@npmcli/fs/lib/common/file-url-to-path/index.js
generated
vendored
@@ -1,17 +0,0 @@
|
|||||||
const url = require('url')
|
|
||||||
|
|
||||||
const node = require('../node.js')
|
|
||||||
const polyfill = require('./polyfill.js')
|
|
||||||
|
|
||||||
const useNative = node.satisfies('>=10.12.0')
|
|
||||||
|
|
||||||
const fileURLToPath = (path) => {
|
|
||||||
// the polyfill is tested separately from this module, no need to hack
|
|
||||||
// process.version to try to trigger it just for coverage
|
|
||||||
// istanbul ignore next
|
|
||||||
return useNative
|
|
||||||
? url.fileURLToPath(path)
|
|
||||||
: polyfill(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = fileURLToPath
|
|
||||||
121
server/node_modules/@npmcli/fs/lib/common/file-url-to-path/polyfill.js
generated
vendored
121
server/node_modules/@npmcli/fs/lib/common/file-url-to-path/polyfill.js
generated
vendored
@@ -1,121 +0,0 @@
|
|||||||
const { URL, domainToUnicode } = require('url')
|
|
||||||
|
|
||||||
const CHAR_LOWERCASE_A = 97
|
|
||||||
const CHAR_LOWERCASE_Z = 122
|
|
||||||
|
|
||||||
const isWindows = process.platform === 'win32'
|
|
||||||
|
|
||||||
class ERR_INVALID_FILE_URL_HOST extends TypeError {
|
|
||||||
constructor (platform) {
|
|
||||||
super(`File URL host must be "localhost" or empty on ${platform}`)
|
|
||||||
this.code = 'ERR_INVALID_FILE_URL_HOST'
|
|
||||||
}
|
|
||||||
|
|
||||||
toString () {
|
|
||||||
return `${this.name} [${this.code}]: ${this.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ERR_INVALID_FILE_URL_PATH extends TypeError {
|
|
||||||
constructor (msg) {
|
|
||||||
super(`File URL path ${msg}`)
|
|
||||||
this.code = 'ERR_INVALID_FILE_URL_PATH'
|
|
||||||
}
|
|
||||||
|
|
||||||
toString () {
|
|
||||||
return `${this.name} [${this.code}]: ${this.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ERR_INVALID_ARG_TYPE extends TypeError {
|
|
||||||
constructor (name, actual) {
|
|
||||||
super(`The "${name}" argument must be one of type string or an instance ` +
|
|
||||||
`of URL. Received type ${typeof actual} ${actual}`)
|
|
||||||
this.code = 'ERR_INVALID_ARG_TYPE'
|
|
||||||
}
|
|
||||||
|
|
||||||
toString () {
|
|
||||||
return `${this.name} [${this.code}]: ${this.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ERR_INVALID_URL_SCHEME extends TypeError {
|
|
||||||
constructor (expected) {
|
|
||||||
super(`The URL must be of scheme ${expected}`)
|
|
||||||
this.code = 'ERR_INVALID_URL_SCHEME'
|
|
||||||
}
|
|
||||||
|
|
||||||
toString () {
|
|
||||||
return `${this.name} [${this.code}]: ${this.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isURLInstance = (input) => {
|
|
||||||
return input != null && input.href && input.origin
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPathFromURLWin32 = (url) => {
|
|
||||||
const hostname = url.hostname
|
|
||||||
let pathname = url.pathname
|
|
||||||
for (let n = 0; n < pathname.length; n++) {
|
|
||||||
if (pathname[n] === '%') {
|
|
||||||
const third = pathname.codePointAt(n + 2) | 0x20
|
|
||||||
if ((pathname[n + 1] === '2' && third === 102) ||
|
|
||||||
(pathname[n + 1] === '5' && third === 99)) {
|
|
||||||
throw new ERR_INVALID_FILE_URL_PATH('must not include encoded \\ or / characters')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pathname = pathname.replace(/\//g, '\\')
|
|
||||||
pathname = decodeURIComponent(pathname)
|
|
||||||
if (hostname !== '') {
|
|
||||||
return `\\\\${domainToUnicode(hostname)}${pathname}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const letter = pathname.codePointAt(1) | 0x20
|
|
||||||
const sep = pathname[2]
|
|
||||||
if (letter < CHAR_LOWERCASE_A || letter > CHAR_LOWERCASE_Z ||
|
|
||||||
(sep !== ':')) {
|
|
||||||
throw new ERR_INVALID_FILE_URL_PATH('must be absolute')
|
|
||||||
}
|
|
||||||
|
|
||||||
return pathname.slice(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPathFromURLPosix = (url) => {
|
|
||||||
if (url.hostname !== '') {
|
|
||||||
throw new ERR_INVALID_FILE_URL_HOST(process.platform)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathname = url.pathname
|
|
||||||
|
|
||||||
for (let n = 0; n < pathname.length; n++) {
|
|
||||||
if (pathname[n] === '%') {
|
|
||||||
const third = pathname.codePointAt(n + 2) | 0x20
|
|
||||||
if (pathname[n + 1] === '2' && third === 102) {
|
|
||||||
throw new ERR_INVALID_FILE_URL_PATH('must not include encoded / characters')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return decodeURIComponent(pathname)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileURLToPath = (path) => {
|
|
||||||
if (typeof path === 'string') {
|
|
||||||
path = new URL(path)
|
|
||||||
} else if (!isURLInstance(path)) {
|
|
||||||
throw new ERR_INVALID_ARG_TYPE('path', ['string', 'URL'], path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path.protocol !== 'file:') {
|
|
||||||
throw new ERR_INVALID_URL_SCHEME('file')
|
|
||||||
}
|
|
||||||
|
|
||||||
return isWindows
|
|
||||||
? getPathFromURLWin32(path)
|
|
||||||
: getPathFromURLPosix(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = fileURLToPath
|
|
||||||
20
server/node_modules/@npmcli/fs/lib/common/get-options.js
generated
vendored
20
server/node_modules/@npmcli/fs/lib/common/get-options.js
generated
vendored
@@ -1,20 +0,0 @@
|
|||||||
// given an input that may or may not be an object, return an object that has
|
|
||||||
// a copy of every defined property listed in 'copy'. if the input is not an
|
|
||||||
// object, assign it to the property named by 'wrap'
|
|
||||||
const getOptions = (input, { copy, wrap }) => {
|
|
||||||
const result = {}
|
|
||||||
|
|
||||||
if (input && typeof input === 'object') {
|
|
||||||
for (const prop of copy) {
|
|
||||||
if (input[prop] !== undefined) {
|
|
||||||
result[prop] = input[prop]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result[wrap] = input
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = getOptions
|
|
||||||
9
server/node_modules/@npmcli/fs/lib/common/node.js
generated
vendored
9
server/node_modules/@npmcli/fs/lib/common/node.js
generated
vendored
@@ -1,9 +0,0 @@
|
|||||||
const semver = require('semver')
|
|
||||||
|
|
||||||
const satisfies = (range) => {
|
|
||||||
return semver.satisfies(process.version, range, { includePrerelease: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
satisfies,
|
|
||||||
}
|
|
||||||
92
server/node_modules/@npmcli/fs/lib/common/owner.js
generated
vendored
92
server/node_modules/@npmcli/fs/lib/common/owner.js
generated
vendored
@@ -1,92 +0,0 @@
|
|||||||
const { dirname, resolve } = require('path')
|
|
||||||
|
|
||||||
const fileURLToPath = require('./file-url-to-path/index.js')
|
|
||||||
const fs = require('../fs.js')
|
|
||||||
|
|
||||||
// given a path, find the owner of the nearest parent
|
|
||||||
const find = async (path) => {
|
|
||||||
// if we have no getuid, permissions are irrelevant on this platform
|
|
||||||
if (!process.getuid) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fs methods accept URL objects with a scheme of file: so we need to unwrap
|
|
||||||
// those into an actual path string before we can resolve it
|
|
||||||
const resolved = path != null && path.href && path.origin
|
|
||||||
? resolve(fileURLToPath(path))
|
|
||||||
: resolve(path)
|
|
||||||
|
|
||||||
let stat
|
|
||||||
|
|
||||||
try {
|
|
||||||
stat = await fs.lstat(resolved)
|
|
||||||
} finally {
|
|
||||||
// if we got a stat, return its contents
|
|
||||||
if (stat) {
|
|
||||||
return { uid: stat.uid, gid: stat.gid }
|
|
||||||
}
|
|
||||||
|
|
||||||
// try the parent directory
|
|
||||||
if (resolved !== dirname(resolved)) {
|
|
||||||
return find(dirname(resolved))
|
|
||||||
}
|
|
||||||
|
|
||||||
// no more parents, never got a stat, just return an empty object
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// given a path, uid, and gid update the ownership of the path if necessary
|
|
||||||
const update = async (path, uid, gid) => {
|
|
||||||
// nothing to update, just exit
|
|
||||||
if (uid === undefined && gid === undefined) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// see if the permissions are already the same, if they are we don't
|
|
||||||
// need to do anything, so return early
|
|
||||||
const stat = await fs.stat(path)
|
|
||||||
if (uid === stat.uid && gid === stat.gid) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (err) {}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.chown(path, uid, gid)
|
|
||||||
} catch (err) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// accepts a `path` and the `owner` property of an options object and normalizes
|
|
||||||
// it into an object with numerical `uid` and `gid`
|
|
||||||
const validate = async (path, input) => {
|
|
||||||
let uid
|
|
||||||
let gid
|
|
||||||
|
|
||||||
if (typeof input === 'string' || typeof input === 'number') {
|
|
||||||
uid = input
|
|
||||||
gid = input
|
|
||||||
} else if (input && typeof input === 'object') {
|
|
||||||
uid = input.uid
|
|
||||||
gid = input.gid
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uid === 'inherit' || gid === 'inherit') {
|
|
||||||
const owner = await find(path)
|
|
||||||
if (uid === 'inherit') {
|
|
||||||
uid = owner.uid
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gid === 'inherit') {
|
|
||||||
gid = owner.gid
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { uid, gid }
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
find,
|
|
||||||
update,
|
|
||||||
validate,
|
|
||||||
}
|
|
||||||
22
server/node_modules/@npmcli/fs/lib/copy-file.js
generated
vendored
22
server/node_modules/@npmcli/fs/lib/copy-file.js
generated
vendored
@@ -1,22 +0,0 @@
|
|||||||
const fs = require('./fs.js')
|
|
||||||
const getOptions = require('./common/get-options.js')
|
|
||||||
const owner = require('./common/owner.js')
|
|
||||||
|
|
||||||
const copyFile = async (src, dest, opts) => {
|
|
||||||
const options = getOptions(opts, {
|
|
||||||
copy: ['mode', 'owner'],
|
|
||||||
wrap: 'mode',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { uid, gid } = await owner.validate(dest, options.owner)
|
|
||||||
|
|
||||||
// the node core method as of 16.5.0 does not support the mode being in an
|
|
||||||
// object, so we have to pass the mode value directly
|
|
||||||
const result = await fs.copyFile(src, dest, options.mode)
|
|
||||||
|
|
||||||
await owner.update(dest, uid, gid)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = copyFile
|
|
||||||
15
server/node_modules/@npmcli/fs/lib/cp/LICENSE
generated
vendored
15
server/node_modules/@npmcli/fs/lib/cp/LICENSE
generated
vendored
@@ -1,15 +0,0 @@
|
|||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2011-2017 JP Richardson
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files
|
|
||||||
(the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify,
|
|
||||||
merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
||||||
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
|
|
||||||
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
||||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
22
server/node_modules/@npmcli/fs/lib/cp/index.js
generated
vendored
22
server/node_modules/@npmcli/fs/lib/cp/index.js
generated
vendored
@@ -1,22 +0,0 @@
|
|||||||
const fs = require('../fs.js')
|
|
||||||
const getOptions = require('../common/get-options.js')
|
|
||||||
const node = require('../common/node.js')
|
|
||||||
const polyfill = require('./polyfill.js')
|
|
||||||
|
|
||||||
// node 16.7.0 added fs.cp
|
|
||||||
const useNative = node.satisfies('>=16.7.0')
|
|
||||||
|
|
||||||
const cp = async (src, dest, opts) => {
|
|
||||||
const options = getOptions(opts, {
|
|
||||||
copy: ['dereference', 'errorOnExist', 'filter', 'force', 'preserveTimestamps', 'recursive'],
|
|
||||||
})
|
|
||||||
|
|
||||||
// the polyfill is tested separately from this module, no need to hack
|
|
||||||
// process.version to try to trigger it just for coverage
|
|
||||||
// istanbul ignore next
|
|
||||||
return useNative
|
|
||||||
? fs.cp(src, dest, options)
|
|
||||||
: polyfill(src, dest, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = cp
|
|
||||||
428
server/node_modules/@npmcli/fs/lib/cp/polyfill.js
generated
vendored
428
server/node_modules/@npmcli/fs/lib/cp/polyfill.js
generated
vendored
@@ -1,428 +0,0 @@
|
|||||||
// this file is a modified version of the code in node 17.2.0
|
|
||||||
// which is, in turn, a modified version of the fs-extra module on npm
|
|
||||||
// node core changes:
|
|
||||||
// - Use of the assert module has been replaced with core's error system.
|
|
||||||
// - All code related to the glob dependency has been removed.
|
|
||||||
// - Bring your own custom fs module is not currently supported.
|
|
||||||
// - Some basic code cleanup.
|
|
||||||
// changes here:
|
|
||||||
// - remove all callback related code
|
|
||||||
// - drop sync support
|
|
||||||
// - change assertions back to non-internal methods (see options.js)
|
|
||||||
// - throws ENOTDIR when rmdir gets an ENOENT for a path that exists in Windows
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
const {
|
|
||||||
ERR_FS_CP_DIR_TO_NON_DIR,
|
|
||||||
ERR_FS_CP_EEXIST,
|
|
||||||
ERR_FS_CP_EINVAL,
|
|
||||||
ERR_FS_CP_FIFO_PIPE,
|
|
||||||
ERR_FS_CP_NON_DIR_TO_DIR,
|
|
||||||
ERR_FS_CP_SOCKET,
|
|
||||||
ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY,
|
|
||||||
ERR_FS_CP_UNKNOWN,
|
|
||||||
ERR_FS_EISDIR,
|
|
||||||
ERR_INVALID_ARG_TYPE,
|
|
||||||
} = require('../errors.js')
|
|
||||||
const {
|
|
||||||
constants: {
|
|
||||||
errno: {
|
|
||||||
EEXIST,
|
|
||||||
EISDIR,
|
|
||||||
EINVAL,
|
|
||||||
ENOTDIR,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} = require('os')
|
|
||||||
const {
|
|
||||||
chmod,
|
|
||||||
copyFile,
|
|
||||||
lstat,
|
|
||||||
mkdir,
|
|
||||||
readdir,
|
|
||||||
readlink,
|
|
||||||
stat,
|
|
||||||
symlink,
|
|
||||||
unlink,
|
|
||||||
utimes,
|
|
||||||
} = require('../fs.js')
|
|
||||||
const {
|
|
||||||
dirname,
|
|
||||||
isAbsolute,
|
|
||||||
join,
|
|
||||||
parse,
|
|
||||||
resolve,
|
|
||||||
sep,
|
|
||||||
toNamespacedPath,
|
|
||||||
} = require('path')
|
|
||||||
const { fileURLToPath } = require('url')
|
|
||||||
|
|
||||||
const defaultOptions = {
|
|
||||||
dereference: false,
|
|
||||||
errorOnExist: false,
|
|
||||||
filter: undefined,
|
|
||||||
force: true,
|
|
||||||
preserveTimestamps: false,
|
|
||||||
recursive: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cp (src, dest, opts) {
|
|
||||||
if (opts != null && typeof opts !== 'object') {
|
|
||||||
throw new ERR_INVALID_ARG_TYPE('options', ['Object'], opts)
|
|
||||||
}
|
|
||||||
return cpFn(
|
|
||||||
toNamespacedPath(getValidatedPath(src)),
|
|
||||||
toNamespacedPath(getValidatedPath(dest)),
|
|
||||||
{ ...defaultOptions, ...opts })
|
|
||||||
}
|
|
||||||
|
|
||||||
function getValidatedPath (fileURLOrPath) {
|
|
||||||
const path = fileURLOrPath != null && fileURLOrPath.href
|
|
||||||
&& fileURLOrPath.origin
|
|
||||||
? fileURLToPath(fileURLOrPath)
|
|
||||||
: fileURLOrPath
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cpFn (src, dest, opts) {
|
|
||||||
// Warn about using preserveTimestamps on 32-bit node
|
|
||||||
// istanbul ignore next
|
|
||||||
if (opts.preserveTimestamps && process.arch === 'ia32') {
|
|
||||||
const warning = 'Using the preserveTimestamps option in 32-bit ' +
|
|
||||||
'node is not recommended'
|
|
||||||
process.emitWarning(warning, 'TimestampPrecisionWarning')
|
|
||||||
}
|
|
||||||
const stats = await checkPaths(src, dest, opts)
|
|
||||||
const { srcStat, destStat } = stats
|
|
||||||
await checkParentPaths(src, srcStat, dest)
|
|
||||||
if (opts.filter) {
|
|
||||||
return handleFilter(checkParentDir, destStat, src, dest, opts)
|
|
||||||
}
|
|
||||||
return checkParentDir(destStat, src, dest, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkPaths (src, dest, opts) {
|
|
||||||
const { 0: srcStat, 1: destStat } = await getStats(src, dest, opts)
|
|
||||||
if (destStat) {
|
|
||||||
if (areIdentical(srcStat, destStat)) {
|
|
||||||
throw new ERR_FS_CP_EINVAL({
|
|
||||||
message: 'src and dest cannot be the same',
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EINVAL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (srcStat.isDirectory() && !destStat.isDirectory()) {
|
|
||||||
throw new ERR_FS_CP_DIR_TO_NON_DIR({
|
|
||||||
message: `cannot overwrite directory ${src} ` +
|
|
||||||
`with non-directory ${dest}`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EISDIR,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!srcStat.isDirectory() && destStat.isDirectory()) {
|
|
||||||
throw new ERR_FS_CP_NON_DIR_TO_DIR({
|
|
||||||
message: `cannot overwrite non-directory ${src} ` +
|
|
||||||
`with directory ${dest}`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: ENOTDIR,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (srcStat.isDirectory() && isSrcSubdir(src, dest)) {
|
|
||||||
throw new ERR_FS_CP_EINVAL({
|
|
||||||
message: `cannot copy ${src} to a subdirectory of self ${dest}`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EINVAL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return { srcStat, destStat }
|
|
||||||
}
|
|
||||||
|
|
||||||
function areIdentical (srcStat, destStat) {
|
|
||||||
return destStat.ino && destStat.dev && destStat.ino === srcStat.ino &&
|
|
||||||
destStat.dev === srcStat.dev
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStats (src, dest, opts) {
|
|
||||||
const statFunc = opts.dereference ?
|
|
||||||
(file) => stat(file, { bigint: true }) :
|
|
||||||
(file) => lstat(file, { bigint: true })
|
|
||||||
return Promise.all([
|
|
||||||
statFunc(src),
|
|
||||||
statFunc(dest).catch((err) => {
|
|
||||||
// istanbul ignore next: unsure how to cover.
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// istanbul ignore next: unsure how to cover.
|
|
||||||
throw err
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkParentDir (destStat, src, dest, opts) {
|
|
||||||
const destParent = dirname(dest)
|
|
||||||
const dirExists = await pathExists(destParent)
|
|
||||||
if (dirExists) {
|
|
||||||
return getStatsForCopy(destStat, src, dest, opts)
|
|
||||||
}
|
|
||||||
await mkdir(destParent, { recursive: true })
|
|
||||||
return getStatsForCopy(destStat, src, dest, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
function pathExists (dest) {
|
|
||||||
return stat(dest).then(
|
|
||||||
() => true,
|
|
||||||
// istanbul ignore next: not sure when this would occur
|
|
||||||
(err) => (err.code === 'ENOENT' ? false : Promise.reject(err)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively check if dest parent is a subdirectory of src.
|
|
||||||
// It works for all file types including symlinks since it
|
|
||||||
// checks the src and dest inodes. It starts from the deepest
|
|
||||||
// parent and stops once it reaches the src parent or the root path.
|
|
||||||
async function checkParentPaths (src, srcStat, dest) {
|
|
||||||
const srcParent = resolve(dirname(src))
|
|
||||||
const destParent = resolve(dirname(dest))
|
|
||||||
if (destParent === srcParent || destParent === parse(destParent).root) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let destStat
|
|
||||||
try {
|
|
||||||
destStat = await stat(destParent, { bigint: true })
|
|
||||||
} catch (err) {
|
|
||||||
// istanbul ignore else: not sure when this would occur
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// istanbul ignore next: not sure when this would occur
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
if (areIdentical(srcStat, destStat)) {
|
|
||||||
throw new ERR_FS_CP_EINVAL({
|
|
||||||
message: `cannot copy ${src} to a subdirectory of self ${dest}`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EINVAL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return checkParentPaths(src, srcStat, destParent)
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizePathToArray = (path) =>
|
|
||||||
resolve(path).split(sep).filter(Boolean)
|
|
||||||
|
|
||||||
// Return true if dest is a subdir of src, otherwise false.
|
|
||||||
// It only checks the path strings.
|
|
||||||
function isSrcSubdir (src, dest) {
|
|
||||||
const srcArr = normalizePathToArray(src)
|
|
||||||
const destArr = normalizePathToArray(dest)
|
|
||||||
return srcArr.every((cur, i) => destArr[i] === cur)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFilter (onInclude, destStat, src, dest, opts, cb) {
|
|
||||||
const include = await opts.filter(src, dest)
|
|
||||||
if (include) {
|
|
||||||
return onInclude(destStat, src, dest, opts, cb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCopy (destStat, src, dest, opts) {
|
|
||||||
if (opts.filter) {
|
|
||||||
return handleFilter(getStatsForCopy, destStat, src, dest, opts)
|
|
||||||
}
|
|
||||||
return getStatsForCopy(destStat, src, dest, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getStatsForCopy (destStat, src, dest, opts) {
|
|
||||||
const statFn = opts.dereference ? stat : lstat
|
|
||||||
const srcStat = await statFn(src)
|
|
||||||
// istanbul ignore else: can't portably test FIFO
|
|
||||||
if (srcStat.isDirectory() && opts.recursive) {
|
|
||||||
return onDir(srcStat, destStat, src, dest, opts)
|
|
||||||
} else if (srcStat.isDirectory()) {
|
|
||||||
throw new ERR_FS_EISDIR({
|
|
||||||
message: `${src} is a directory (not copied)`,
|
|
||||||
path: src,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EINVAL,
|
|
||||||
})
|
|
||||||
} else if (srcStat.isFile() ||
|
|
||||||
srcStat.isCharacterDevice() ||
|
|
||||||
srcStat.isBlockDevice()) {
|
|
||||||
return onFile(srcStat, destStat, src, dest, opts)
|
|
||||||
} else if (srcStat.isSymbolicLink()) {
|
|
||||||
return onLink(destStat, src, dest)
|
|
||||||
} else if (srcStat.isSocket()) {
|
|
||||||
throw new ERR_FS_CP_SOCKET({
|
|
||||||
message: `cannot copy a socket file: ${dest}`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EINVAL,
|
|
||||||
})
|
|
||||||
} else if (srcStat.isFIFO()) {
|
|
||||||
throw new ERR_FS_CP_FIFO_PIPE({
|
|
||||||
message: `cannot copy a FIFO pipe: ${dest}`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EINVAL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// istanbul ignore next: should be unreachable
|
|
||||||
throw new ERR_FS_CP_UNKNOWN({
|
|
||||||
message: `cannot copy an unknown file type: ${dest}`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EINVAL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFile (srcStat, destStat, src, dest, opts) {
|
|
||||||
if (!destStat) {
|
|
||||||
return _copyFile(srcStat, src, dest, opts)
|
|
||||||
}
|
|
||||||
return mayCopyFile(srcStat, src, dest, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mayCopyFile (srcStat, src, dest, opts) {
|
|
||||||
if (opts.force) {
|
|
||||||
await unlink(dest)
|
|
||||||
return _copyFile(srcStat, src, dest, opts)
|
|
||||||
} else if (opts.errorOnExist) {
|
|
||||||
throw new ERR_FS_CP_EEXIST({
|
|
||||||
message: `${dest} already exists`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EEXIST,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _copyFile (srcStat, src, dest, opts) {
|
|
||||||
await copyFile(src, dest)
|
|
||||||
if (opts.preserveTimestamps) {
|
|
||||||
return handleTimestampsAndMode(srcStat.mode, src, dest)
|
|
||||||
}
|
|
||||||
return setDestMode(dest, srcStat.mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleTimestampsAndMode (srcMode, src, dest) {
|
|
||||||
// Make sure the file is writable before setting the timestamp
|
|
||||||
// otherwise open fails with EPERM when invoked with 'r+'
|
|
||||||
// (through utimes call)
|
|
||||||
if (fileIsNotWritable(srcMode)) {
|
|
||||||
await makeFileWritable(dest, srcMode)
|
|
||||||
return setDestTimestampsAndMode(srcMode, src, dest)
|
|
||||||
}
|
|
||||||
return setDestTimestampsAndMode(srcMode, src, dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileIsNotWritable (srcMode) {
|
|
||||||
return (srcMode & 0o200) === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFileWritable (dest, srcMode) {
|
|
||||||
return setDestMode(dest, srcMode | 0o200)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setDestTimestampsAndMode (srcMode, src, dest) {
|
|
||||||
await setDestTimestamps(src, dest)
|
|
||||||
return setDestMode(dest, srcMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDestMode (dest, srcMode) {
|
|
||||||
return chmod(dest, srcMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setDestTimestamps (src, dest) {
|
|
||||||
// The initial srcStat.atime cannot be trusted
|
|
||||||
// because it is modified by the read(2) system call
|
|
||||||
// (See https://nodejs.org/api/fs.html#fs_stat_time_values)
|
|
||||||
const updatedSrcStat = await stat(src)
|
|
||||||
return utimes(dest, updatedSrcStat.atime, updatedSrcStat.mtime)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDir (srcStat, destStat, src, dest, opts) {
|
|
||||||
if (!destStat) {
|
|
||||||
return mkDirAndCopy(srcStat.mode, src, dest, opts)
|
|
||||||
}
|
|
||||||
return copyDir(src, dest, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mkDirAndCopy (srcMode, src, dest, opts) {
|
|
||||||
await mkdir(dest)
|
|
||||||
await copyDir(src, dest, opts)
|
|
||||||
return setDestMode(dest, srcMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyDir (src, dest, opts) {
|
|
||||||
const dir = await readdir(src)
|
|
||||||
for (let i = 0; i < dir.length; i++) {
|
|
||||||
const item = dir[i]
|
|
||||||
const srcItem = join(src, item)
|
|
||||||
const destItem = join(dest, item)
|
|
||||||
const { destStat } = await checkPaths(srcItem, destItem, opts)
|
|
||||||
await startCopy(destStat, srcItem, destItem, opts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onLink (destStat, src, dest) {
|
|
||||||
let resolvedSrc = await readlink(src)
|
|
||||||
if (!isAbsolute(resolvedSrc)) {
|
|
||||||
resolvedSrc = resolve(dirname(src), resolvedSrc)
|
|
||||||
}
|
|
||||||
if (!destStat) {
|
|
||||||
return symlink(resolvedSrc, dest)
|
|
||||||
}
|
|
||||||
let resolvedDest
|
|
||||||
try {
|
|
||||||
resolvedDest = await readlink(dest)
|
|
||||||
} catch (err) {
|
|
||||||
// Dest exists and is a regular file or directory,
|
|
||||||
// Windows may throw UNKNOWN error. If dest already exists,
|
|
||||||
// fs throws error anyway, so no need to guard against it here.
|
|
||||||
// istanbul ignore next: can only test on windows
|
|
||||||
if (err.code === 'EINVAL' || err.code === 'UNKNOWN') {
|
|
||||||
return symlink(resolvedSrc, dest)
|
|
||||||
}
|
|
||||||
// istanbul ignore next: should not be possible
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
if (!isAbsolute(resolvedDest)) {
|
|
||||||
resolvedDest = resolve(dirname(dest), resolvedDest)
|
|
||||||
}
|
|
||||||
if (isSrcSubdir(resolvedSrc, resolvedDest)) {
|
|
||||||
throw new ERR_FS_CP_EINVAL({
|
|
||||||
message: `cannot copy ${resolvedSrc} to a subdirectory of self ` +
|
|
||||||
`${resolvedDest}`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EINVAL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// Do not copy if src is a subdir of dest since unlinking
|
|
||||||
// dest in this case would result in removing src contents
|
|
||||||
// and therefore a broken symlink would be created.
|
|
||||||
const srcStat = await stat(src)
|
|
||||||
if (srcStat.isDirectory() && isSrcSubdir(resolvedDest, resolvedSrc)) {
|
|
||||||
throw new ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY({
|
|
||||||
message: `cannot overwrite ${resolvedDest} with ${resolvedSrc}`,
|
|
||||||
path: dest,
|
|
||||||
syscall: 'cp',
|
|
||||||
errno: EINVAL,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return copyLink(resolvedSrc, dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function copyLink (resolvedSrc, dest) {
|
|
||||||
await unlink(dest)
|
|
||||||
return symlink(resolvedSrc, dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = cp
|
|
||||||
129
server/node_modules/@npmcli/fs/lib/errors.js
generated
vendored
129
server/node_modules/@npmcli/fs/lib/errors.js
generated
vendored
@@ -1,129 +0,0 @@
|
|||||||
'use strict'
|
|
||||||
const { inspect } = require('util')
|
|
||||||
|
|
||||||
// adapted from node's internal/errors
|
|
||||||
// https://github.com/nodejs/node/blob/c8a04049/lib/internal/errors.js
|
|
||||||
|
|
||||||
// close copy of node's internal SystemError class.
|
|
||||||
class SystemError {
|
|
||||||
constructor (code, prefix, context) {
|
|
||||||
// XXX context.code is undefined in all constructors used in cp/polyfill
|
|
||||||
// that may be a bug copied from node, maybe the constructor should use
|
|
||||||
// `code` not `errno`? nodejs/node#41104
|
|
||||||
let message = `${prefix}: ${context.syscall} returned ` +
|
|
||||||
`${context.code} (${context.message})`
|
|
||||||
|
|
||||||
if (context.path !== undefined) {
|
|
||||||
message += ` ${context.path}`
|
|
||||||
}
|
|
||||||
if (context.dest !== undefined) {
|
|
||||||
message += ` => ${context.dest}`
|
|
||||||
}
|
|
||||||
|
|
||||||
this.code = code
|
|
||||||
Object.defineProperties(this, {
|
|
||||||
name: {
|
|
||||||
value: 'SystemError',
|
|
||||||
enumerable: false,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
value: message,
|
|
||||||
enumerable: false,
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
value: context,
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
writable: false,
|
|
||||||
},
|
|
||||||
errno: {
|
|
||||||
get () {
|
|
||||||
return context.errno
|
|
||||||
},
|
|
||||||
set (value) {
|
|
||||||
context.errno = value
|
|
||||||
},
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
},
|
|
||||||
syscall: {
|
|
||||||
get () {
|
|
||||||
return context.syscall
|
|
||||||
},
|
|
||||||
set (value) {
|
|
||||||
context.syscall = value
|
|
||||||
},
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (context.path !== undefined) {
|
|
||||||
Object.defineProperty(this, 'path', {
|
|
||||||
get () {
|
|
||||||
return context.path
|
|
||||||
},
|
|
||||||
set (value) {
|
|
||||||
context.path = value
|
|
||||||
},
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.dest !== undefined) {
|
|
||||||
Object.defineProperty(this, 'dest', {
|
|
||||||
get () {
|
|
||||||
return context.dest
|
|
||||||
},
|
|
||||||
set (value) {
|
|
||||||
context.dest = value
|
|
||||||
},
|
|
||||||
enumerable: true,
|
|
||||||
configurable: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toString () {
|
|
||||||
return `${this.name} [${this.code}]: ${this.message}`
|
|
||||||
}
|
|
||||||
|
|
||||||
[Symbol.for('nodejs.util.inspect.custom')] (_recurseTimes, ctx) {
|
|
||||||
return inspect(this, {
|
|
||||||
...ctx,
|
|
||||||
getters: true,
|
|
||||||
customInspect: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function E (code, message) {
|
|
||||||
module.exports[code] = class NodeError extends SystemError {
|
|
||||||
constructor (ctx) {
|
|
||||||
super(code, message, ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
E('ERR_FS_CP_DIR_TO_NON_DIR', 'Cannot overwrite directory with non-directory')
|
|
||||||
E('ERR_FS_CP_EEXIST', 'Target already exists')
|
|
||||||
E('ERR_FS_CP_EINVAL', 'Invalid src or dest')
|
|
||||||
E('ERR_FS_CP_FIFO_PIPE', 'Cannot copy a FIFO pipe')
|
|
||||||
E('ERR_FS_CP_NON_DIR_TO_DIR', 'Cannot overwrite non-directory with directory')
|
|
||||||
E('ERR_FS_CP_SOCKET', 'Cannot copy a socket file')
|
|
||||||
E('ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY', 'Cannot overwrite symlink in subdirectory of self')
|
|
||||||
E('ERR_FS_CP_UNKNOWN', 'Cannot copy an unknown file type')
|
|
||||||
E('ERR_FS_EISDIR', 'Path is a directory')
|
|
||||||
|
|
||||||
module.exports.ERR_INVALID_ARG_TYPE = class ERR_INVALID_ARG_TYPE extends Error {
|
|
||||||
constructor (name, expected, actual) {
|
|
||||||
super()
|
|
||||||
this.code = 'ERR_INVALID_ARG_TYPE'
|
|
||||||
this.message = `The ${name} argument must be ${expected}. Received ${typeof actual}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
8
server/node_modules/@npmcli/fs/lib/fs.js
generated
vendored
8
server/node_modules/@npmcli/fs/lib/fs.js
generated
vendored
@@ -1,8 +0,0 @@
|
|||||||
const fs = require('fs')
|
|
||||||
const promisify = require('@gar/promisify')
|
|
||||||
|
|
||||||
// this module returns the core fs module wrapped in a proxy that promisifies
|
|
||||||
// method calls within the getter. we keep it in a separate module so that the
|
|
||||||
// overridden methods have a consistent way to get to promisified fs methods
|
|
||||||
// without creating a circular dependency
|
|
||||||
module.exports = promisify(fs)
|
|
||||||
10
server/node_modules/@npmcli/fs/lib/index.js
generated
vendored
10
server/node_modules/@npmcli/fs/lib/index.js
generated
vendored
@@ -1,10 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
...require('./fs.js'),
|
|
||||||
copyFile: require('./copy-file.js'),
|
|
||||||
cp: require('./cp/index.js'),
|
|
||||||
mkdir: require('./mkdir/index.js'),
|
|
||||||
mkdtemp: require('./mkdtemp.js'),
|
|
||||||
rm: require('./rm/index.js'),
|
|
||||||
withTempDir: require('./with-temp-dir.js'),
|
|
||||||
writeFile: require('./write-file.js'),
|
|
||||||
}
|
|
||||||
32
server/node_modules/@npmcli/fs/lib/mkdir/index.js
generated
vendored
32
server/node_modules/@npmcli/fs/lib/mkdir/index.js
generated
vendored
@@ -1,32 +0,0 @@
|
|||||||
const fs = require('../fs.js')
|
|
||||||
const getOptions = require('../common/get-options.js')
|
|
||||||
const node = require('../common/node.js')
|
|
||||||
const owner = require('../common/owner.js')
|
|
||||||
|
|
||||||
const polyfill = require('./polyfill.js')
|
|
||||||
|
|
||||||
// node 10.12.0 added the options parameter, which allows recursive and mode
|
|
||||||
// properties to be passed
|
|
||||||
const useNative = node.satisfies('>=10.12.0')
|
|
||||||
|
|
||||||
// extends mkdir with the ability to specify an owner of the new dir
|
|
||||||
const mkdir = async (path, opts) => {
|
|
||||||
const options = getOptions(opts, {
|
|
||||||
copy: ['mode', 'recursive', 'owner'],
|
|
||||||
wrap: 'mode',
|
|
||||||
})
|
|
||||||
const { uid, gid } = await owner.validate(path, options.owner)
|
|
||||||
|
|
||||||
// the polyfill is tested separately from this module, no need to hack
|
|
||||||
// process.version to try to trigger it just for coverage
|
|
||||||
// istanbul ignore next
|
|
||||||
const result = useNative
|
|
||||||
? await fs.mkdir(path, options)
|
|
||||||
: await polyfill(path, options)
|
|
||||||
|
|
||||||
await owner.update(path, uid, gid)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = mkdir
|
|
||||||
81
server/node_modules/@npmcli/fs/lib/mkdir/polyfill.js
generated
vendored
81
server/node_modules/@npmcli/fs/lib/mkdir/polyfill.js
generated
vendored
@@ -1,81 +0,0 @@
|
|||||||
const { dirname } = require('path')
|
|
||||||
|
|
||||||
const fileURLToPath = require('../common/file-url-to-path/index.js')
|
|
||||||
const fs = require('../fs.js')
|
|
||||||
|
|
||||||
const defaultOptions = {
|
|
||||||
mode: 0o777,
|
|
||||||
recursive: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
const mkdir = async (path, opts) => {
|
|
||||||
const options = { ...defaultOptions, ...opts }
|
|
||||||
|
|
||||||
// if we're not in recursive mode, just call the real mkdir with the path and
|
|
||||||
// the mode option only
|
|
||||||
if (!options.recursive) {
|
|
||||||
return fs.mkdir(path, options.mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeDirectory = async (dir, mode) => {
|
|
||||||
// we can't use dirname directly since these functions support URL
|
|
||||||
// objects with the file: protocol as the path input, so first we get a
|
|
||||||
// string path, then we can call dirname on that
|
|
||||||
const parent = dir != null && dir.href && dir.origin
|
|
||||||
? dirname(fileURLToPath(dir))
|
|
||||||
: dirname(dir)
|
|
||||||
|
|
||||||
// if the parent is the dir itself, try to create it. anything but EISDIR
|
|
||||||
// should be rethrown
|
|
||||||
if (parent === dir) {
|
|
||||||
try {
|
|
||||||
await fs.mkdir(dir, opts)
|
|
||||||
} catch (err) {
|
|
||||||
if (err.code !== 'EISDIR') {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fs.mkdir(dir, mode)
|
|
||||||
return dir
|
|
||||||
} catch (err) {
|
|
||||||
// ENOENT means the parent wasn't there, so create that
|
|
||||||
if (err.code === 'ENOENT') {
|
|
||||||
const made = await makeDirectory(parent, mode)
|
|
||||||
await makeDirectory(dir, mode)
|
|
||||||
// return the shallowest path we created, i.e. the result of creating
|
|
||||||
// the parent
|
|
||||||
return made
|
|
||||||
}
|
|
||||||
|
|
||||||
// an EEXIST means there's already something there
|
|
||||||
// an EROFS means we have a read-only filesystem and can't create a dir
|
|
||||||
// any other error is fatal and we should give up now
|
|
||||||
if (err.code !== 'EEXIST' && err.code !== 'EROFS') {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
// stat the directory, if the result is a directory, then we successfully
|
|
||||||
// created this one so return its path. otherwise, we reject with the
|
|
||||||
// original error by ignoring the error in the catch
|
|
||||||
try {
|
|
||||||
const stat = await fs.stat(dir)
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
// if it already existed, we didn't create anything so return
|
|
||||||
// undefined
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
|
|
||||||
// if the thing that's there isn't a directory, then just re-throw
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return makeDirectory(path, options.mode)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = mkdir
|
|
||||||
28
server/node_modules/@npmcli/fs/lib/mkdtemp.js
generated
vendored
28
server/node_modules/@npmcli/fs/lib/mkdtemp.js
generated
vendored
@@ -1,28 +0,0 @@
|
|||||||
const { dirname, sep } = require('path')
|
|
||||||
|
|
||||||
const fs = require('./fs.js')
|
|
||||||
const getOptions = require('./common/get-options.js')
|
|
||||||
const owner = require('./common/owner.js')
|
|
||||||
|
|
||||||
const mkdtemp = async (prefix, opts) => {
|
|
||||||
const options = getOptions(opts, {
|
|
||||||
copy: ['encoding', 'owner'],
|
|
||||||
wrap: 'encoding',
|
|
||||||
})
|
|
||||||
|
|
||||||
// mkdtemp relies on the trailing path separator to indicate if it should
|
|
||||||
// create a directory inside of the prefix. if that's the case then the root
|
|
||||||
// we infer ownership from is the prefix itself, otherwise it's the dirname
|
|
||||||
// /tmp -> /tmpABCDEF, infers from /
|
|
||||||
// /tmp/ -> /tmp/ABCDEF, infers from /tmp
|
|
||||||
const root = prefix.endsWith(sep) ? prefix : dirname(prefix)
|
|
||||||
const { uid, gid } = await owner.validate(root, options.owner)
|
|
||||||
|
|
||||||
const result = await fs.mkdtemp(prefix, options)
|
|
||||||
|
|
||||||
await owner.update(result, uid, gid)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = mkdtemp
|
|
||||||
22
server/node_modules/@npmcli/fs/lib/rm/index.js
generated
vendored
22
server/node_modules/@npmcli/fs/lib/rm/index.js
generated
vendored
@@ -1,22 +0,0 @@
|
|||||||
const fs = require('../fs.js')
|
|
||||||
const getOptions = require('../common/get-options.js')
|
|
||||||
const node = require('../common/node.js')
|
|
||||||
const polyfill = require('./polyfill.js')
|
|
||||||
|
|
||||||
// node 14.14.0 added fs.rm, which allows both the force and recursive options
|
|
||||||
const useNative = node.satisfies('>=14.14.0')
|
|
||||||
|
|
||||||
const rm = async (path, opts) => {
|
|
||||||
const options = getOptions(opts, {
|
|
||||||
copy: ['retryDelay', 'maxRetries', 'recursive', 'force'],
|
|
||||||
})
|
|
||||||
|
|
||||||
// the polyfill is tested separately from this module, no need to hack
|
|
||||||
// process.version to try to trigger it just for coverage
|
|
||||||
// istanbul ignore next
|
|
||||||
return useNative
|
|
||||||
? fs.rm(path, options)
|
|
||||||
: polyfill(path, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = rm
|
|
||||||
239
server/node_modules/@npmcli/fs/lib/rm/polyfill.js
generated
vendored
239
server/node_modules/@npmcli/fs/lib/rm/polyfill.js
generated
vendored
@@ -1,239 +0,0 @@
|
|||||||
// this file is a modified version of the code in node core >=14.14.0
|
|
||||||
// which is, in turn, a modified version of the rimraf module on npm
|
|
||||||
// node core changes:
|
|
||||||
// - Use of the assert module has been replaced with core's error system.
|
|
||||||
// - All code related to the glob dependency has been removed.
|
|
||||||
// - Bring your own custom fs module is not currently supported.
|
|
||||||
// - Some basic code cleanup.
|
|
||||||
// changes here:
|
|
||||||
// - remove all callback related code
|
|
||||||
// - drop sync support
|
|
||||||
// - change assertions back to non-internal methods (see options.js)
|
|
||||||
// - throws ENOTDIR when rmdir gets an ENOENT for a path that exists in Windows
|
|
||||||
const errnos = require('os').constants.errno
|
|
||||||
const { join } = require('path')
|
|
||||||
const fs = require('../fs.js')
|
|
||||||
|
|
||||||
// error codes that mean we need to remove contents
|
|
||||||
const notEmptyCodes = new Set([
|
|
||||||
'ENOTEMPTY',
|
|
||||||
'EEXIST',
|
|
||||||
'EPERM',
|
|
||||||
])
|
|
||||||
|
|
||||||
// error codes we can retry later
|
|
||||||
const retryCodes = new Set([
|
|
||||||
'EBUSY',
|
|
||||||
'EMFILE',
|
|
||||||
'ENFILE',
|
|
||||||
'ENOTEMPTY',
|
|
||||||
'EPERM',
|
|
||||||
])
|
|
||||||
|
|
||||||
const isWindows = process.platform === 'win32'
|
|
||||||
|
|
||||||
const defaultOptions = {
|
|
||||||
retryDelay: 100,
|
|
||||||
maxRetries: 0,
|
|
||||||
recursive: false,
|
|
||||||
force: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
// this is drastically simplified, but should be roughly equivalent to what
|
|
||||||
// node core throws
|
|
||||||
class ERR_FS_EISDIR extends Error {
|
|
||||||
constructor (path) {
|
|
||||||
super()
|
|
||||||
this.info = {
|
|
||||||
code: 'EISDIR',
|
|
||||||
message: 'is a directory',
|
|
||||||
path,
|
|
||||||
syscall: 'rm',
|
|
||||||
errno: errnos.EISDIR,
|
|
||||||
}
|
|
||||||
this.name = 'SystemError'
|
|
||||||
this.code = 'ERR_FS_EISDIR'
|
|
||||||
this.errno = errnos.EISDIR
|
|
||||||
this.syscall = 'rm'
|
|
||||||
this.path = path
|
|
||||||
this.message = `Path is a directory: ${this.syscall} returned ` +
|
|
||||||
`${this.info.code} (is a directory) ${path}`
|
|
||||||
}
|
|
||||||
|
|
||||||
toString () {
|
|
||||||
return `${this.name} [${this.code}]: ${this.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ENOTDIR extends Error {
|
|
||||||
constructor (path) {
|
|
||||||
super()
|
|
||||||
this.name = 'Error'
|
|
||||||
this.code = 'ENOTDIR'
|
|
||||||
this.errno = errnos.ENOTDIR
|
|
||||||
this.syscall = 'rmdir'
|
|
||||||
this.path = path
|
|
||||||
this.message = `not a directory, ${this.syscall} '${this.path}'`
|
|
||||||
}
|
|
||||||
|
|
||||||
toString () {
|
|
||||||
return `${this.name}: ${this.code}: ${this.message}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// force is passed separately here because we respect it for the first entry
|
|
||||||
// into rimraf only, any further calls that are spawned as a result (i.e. to
|
|
||||||
// delete content within the target) will ignore ENOENT errors
|
|
||||||
const rimraf = async (path, options, isTop = false) => {
|
|
||||||
const force = isTop ? options.force : true
|
|
||||||
const stat = await fs.lstat(path)
|
|
||||||
.catch((err) => {
|
|
||||||
// we only ignore ENOENT if we're forcing this call
|
|
||||||
if (err.code === 'ENOENT' && force) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isWindows && err.code === 'EPERM') {
|
|
||||||
return fixEPERM(path, options, err, isTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
|
|
||||||
// no stat object here means either lstat threw an ENOENT, or lstat threw
|
|
||||||
// an EPERM and the fixPERM function took care of things. either way, we're
|
|
||||||
// already done, so return early
|
|
||||||
if (!stat) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
return rmdir(path, options, null, isTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs.unlink(path)
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.code === 'ENOENT' && force) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err.code === 'EISDIR') {
|
|
||||||
return rmdir(path, options, err, isTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err.code === 'EPERM') {
|
|
||||||
// in windows, we handle this through fixEPERM which will also try to
|
|
||||||
// delete things again. everywhere else since deleting the target as a
|
|
||||||
// file didn't work we go ahead and try to delete it as a directory
|
|
||||||
return isWindows
|
|
||||||
? fixEPERM(path, options, err, isTop)
|
|
||||||
: rmdir(path, options, err, isTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixEPERM = async (path, options, originalErr, isTop) => {
|
|
||||||
const force = isTop ? options.force : true
|
|
||||||
const targetMissing = await fs.chmod(path, 0o666)
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.code === 'ENOENT' && force) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
throw originalErr
|
|
||||||
})
|
|
||||||
|
|
||||||
// got an ENOENT above, return now. no file = no problem
|
|
||||||
if (targetMissing) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// this function does its own lstat rather than calling rimraf again to avoid
|
|
||||||
// infinite recursion for a repeating EPERM
|
|
||||||
const stat = await fs.lstat(path)
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.code === 'ENOENT' && force) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
throw originalErr
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!stat) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
return rmdir(path, options, originalErr, isTop)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs.unlink(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rmdir = async (path, options, originalErr, isTop) => {
|
|
||||||
if (!options.recursive && isTop) {
|
|
||||||
throw originalErr || new ERR_FS_EISDIR(path)
|
|
||||||
}
|
|
||||||
const force = isTop ? options.force : true
|
|
||||||
|
|
||||||
return fs.rmdir(path)
|
|
||||||
.catch(async (err) => {
|
|
||||||
// in Windows, calling rmdir on a file path will fail with ENOENT rather
|
|
||||||
// than ENOTDIR. to determine if that's what happened, we have to do
|
|
||||||
// another lstat on the path. if the path isn't actually gone, we throw
|
|
||||||
// away the ENOENT and replace it with our own ENOTDIR
|
|
||||||
if (isWindows && err.code === 'ENOENT') {
|
|
||||||
const stillExists = await fs.lstat(path).then(() => true, () => false)
|
|
||||||
if (stillExists) {
|
|
||||||
err = new ENOTDIR(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// not there, not a problem
|
|
||||||
if (err.code === 'ENOENT' && force) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// we may not have originalErr if lstat tells us our target is a
|
|
||||||
// directory but that changes before we actually remove it, so
|
|
||||||
// only throw it here if it's set
|
|
||||||
if (originalErr && err.code === 'ENOTDIR') {
|
|
||||||
throw originalErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// the directory isn't empty, remove the contents and try again
|
|
||||||
if (notEmptyCodes.has(err.code)) {
|
|
||||||
const files = await fs.readdir(path)
|
|
||||||
await Promise.all(files.map((file) => {
|
|
||||||
const target = join(path, file)
|
|
||||||
return rimraf(target, options)
|
|
||||||
}))
|
|
||||||
return fs.rmdir(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const rm = async (path, opts) => {
|
|
||||||
const options = { ...defaultOptions, ...opts }
|
|
||||||
let retries = 0
|
|
||||||
|
|
||||||
const errHandler = async (err) => {
|
|
||||||
if (retryCodes.has(err.code) && ++retries < options.maxRetries) {
|
|
||||||
const delay = retries * options.retryDelay
|
|
||||||
await promiseTimeout(delay)
|
|
||||||
return rimraf(path, options, true).catch(errHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
return rimraf(path, options, true).catch(errHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
const promiseTimeout = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
||||||
|
|
||||||
module.exports = rm
|
|
||||||
39
server/node_modules/@npmcli/fs/lib/with-temp-dir.js
generated
vendored
39
server/node_modules/@npmcli/fs/lib/with-temp-dir.js
generated
vendored
@@ -1,39 +0,0 @@
|
|||||||
const { join, sep } = require('path')
|
|
||||||
|
|
||||||
const getOptions = require('./common/get-options.js')
|
|
||||||
const mkdir = require('./mkdir/index.js')
|
|
||||||
const mkdtemp = require('./mkdtemp.js')
|
|
||||||
const rm = require('./rm/index.js')
|
|
||||||
|
|
||||||
// create a temp directory, ensure its permissions match its parent, then call
|
|
||||||
// the supplied function passing it the path to the directory. clean up after
|
|
||||||
// the function finishes, whether it throws or not
|
|
||||||
const withTempDir = async (root, fn, opts) => {
|
|
||||||
const options = getOptions(opts, {
|
|
||||||
copy: ['tmpPrefix'],
|
|
||||||
})
|
|
||||||
// create the directory, and fix its ownership
|
|
||||||
await mkdir(root, { recursive: true, owner: 'inherit' })
|
|
||||||
|
|
||||||
const target = await mkdtemp(join(`${root}${sep}`, options.tmpPrefix || ''), { owner: 'inherit' })
|
|
||||||
let err
|
|
||||||
let result
|
|
||||||
|
|
||||||
try {
|
|
||||||
result = await fn(target)
|
|
||||||
} catch (_err) {
|
|
||||||
err = _err
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await rm(target, { force: true, recursive: true })
|
|
||||||
} catch (err) {}
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = withTempDir
|
|
||||||
19
server/node_modules/@npmcli/fs/lib/write-file.js
generated
vendored
19
server/node_modules/@npmcli/fs/lib/write-file.js
generated
vendored
@@ -1,19 +0,0 @@
|
|||||||
const fs = require('./fs.js')
|
|
||||||
const getOptions = require('./common/get-options.js')
|
|
||||||
const owner = require('./common/owner.js')
|
|
||||||
|
|
||||||
const writeFile = async (file, data, opts) => {
|
|
||||||
const options = getOptions(opts, {
|
|
||||||
copy: ['encoding', 'mode', 'flag', 'signal', 'owner'],
|
|
||||||
wrap: 'encoding',
|
|
||||||
})
|
|
||||||
const { uid, gid } = await owner.validate(file, options.owner)
|
|
||||||
|
|
||||||
const result = await fs.writeFile(file, data, options)
|
|
||||||
|
|
||||||
await owner.update(file, uid, gid)
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = writeFile
|
|
||||||
38
server/node_modules/@npmcli/fs/package.json
generated
vendored
38
server/node_modules/@npmcli/fs/package.json
generated
vendored
@@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@npmcli/fs",
|
|
||||||
"version": "1.1.1",
|
|
||||||
"description": "filesystem utilities for the npm cli",
|
|
||||||
"main": "lib/index.js",
|
|
||||||
"files": [
|
|
||||||
"bin",
|
|
||||||
"lib"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"preversion": "npm test",
|
|
||||||
"postversion": "npm publish",
|
|
||||||
"prepublishOnly": "git push origin --follow-tags",
|
|
||||||
"snap": "tap",
|
|
||||||
"test": "tap",
|
|
||||||
"npmclilint": "npmcli-lint",
|
|
||||||
"lint": "eslint '**/*.js'",
|
|
||||||
"lintfix": "npm run lint -- --fix",
|
|
||||||
"posttest": "npm run lint",
|
|
||||||
"postsnap": "npm run lintfix --",
|
|
||||||
"postlint": "npm-template-check"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"npm",
|
|
||||||
"oss"
|
|
||||||
],
|
|
||||||
"author": "GitHub Inc.",
|
|
||||||
"license": "ISC",
|
|
||||||
"devDependencies": {
|
|
||||||
"@npmcli/template-oss": "^2.3.1",
|
|
||||||
"tap": "^15.0.9"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@gar/promisify": "^1.0.1",
|
|
||||||
"semver": "^7.3.5"
|
|
||||||
},
|
|
||||||
"templateVersion": "2.3.1"
|
|
||||||
}
|
|
||||||
22
server/node_modules/@npmcli/move-file/LICENSE.md
generated
vendored
22
server/node_modules/@npmcli/move-file/LICENSE.md
generated
vendored
@@ -1,22 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
|
||||||
Copyright (c) npm, Inc.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a
|
|
||||||
copy of this software and associated documentation files (the "Software"),
|
|
||||||
to deal in the Software without restriction, including without limitation
|
|
||||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
||||||
and/or sell copies of the Software, and to permit persons to whom the
|
|
||||||
Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
||||||
DEALINGS IN THE SOFTWARE.
|
|
||||||
69
server/node_modules/@npmcli/move-file/README.md
generated
vendored
69
server/node_modules/@npmcli/move-file/README.md
generated
vendored
@@ -1,69 +0,0 @@
|
|||||||
# @npmcli/move-file
|
|
||||||
|
|
||||||
A fork of [move-file](https://github.com/sindresorhus/move-file) with
|
|
||||||
compatibility with all node 10.x versions.
|
|
||||||
|
|
||||||
> Move a file (or directory)
|
|
||||||
|
|
||||||
The built-in
|
|
||||||
[`fs.rename()`](https://nodejs.org/api/fs.html#fs_fs_rename_oldpath_newpath_callback)
|
|
||||||
is just a JavaScript wrapper for the C `rename(2)` function, which doesn't
|
|
||||||
support moving files across partitions or devices. This module is what you
|
|
||||||
would have expected `fs.rename()` to be.
|
|
||||||
|
|
||||||
## Highlights
|
|
||||||
|
|
||||||
- Promise API.
|
|
||||||
- Supports moving a file across partitions and devices.
|
|
||||||
- Optionally prevent overwriting an existing file.
|
|
||||||
- Creates non-existent destination directories for you.
|
|
||||||
- Support for Node versions that lack built-in recursive `fs.mkdir()`
|
|
||||||
- Automatically recurses when source is a directory.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```
|
|
||||||
$ npm install @npmcli/move-file
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```js
|
|
||||||
const moveFile = require('@npmcli/move-file');
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
await moveFile('source/unicorn.png', 'destination/unicorn.png');
|
|
||||||
console.log('The file has been moved');
|
|
||||||
})();
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
### moveFile(source, destination, options?)
|
|
||||||
|
|
||||||
Returns a `Promise` that resolves when the file has been moved.
|
|
||||||
|
|
||||||
### moveFile.sync(source, destination, options?)
|
|
||||||
|
|
||||||
#### source
|
|
||||||
|
|
||||||
Type: `string`
|
|
||||||
|
|
||||||
File, or directory, you want to move.
|
|
||||||
|
|
||||||
#### destination
|
|
||||||
|
|
||||||
Type: `string`
|
|
||||||
|
|
||||||
Where you want the file or directory moved.
|
|
||||||
|
|
||||||
#### options
|
|
||||||
|
|
||||||
Type: `object`
|
|
||||||
|
|
||||||
##### overwrite
|
|
||||||
|
|
||||||
Type: `boolean`\
|
|
||||||
Default: `true`
|
|
||||||
|
|
||||||
Overwrite existing destination file(s).
|
|
||||||
162
server/node_modules/@npmcli/move-file/index.js
generated
vendored
162
server/node_modules/@npmcli/move-file/index.js
generated
vendored
@@ -1,162 +0,0 @@
|
|||||||
const { dirname, join, resolve, relative, isAbsolute } = require('path')
|
|
||||||
const rimraf_ = require('rimraf')
|
|
||||||
const { promisify } = require('util')
|
|
||||||
const {
|
|
||||||
access: access_,
|
|
||||||
accessSync,
|
|
||||||
copyFile: copyFile_,
|
|
||||||
copyFileSync,
|
|
||||||
unlink: unlink_,
|
|
||||||
unlinkSync,
|
|
||||||
readdir: readdir_,
|
|
||||||
readdirSync,
|
|
||||||
rename: rename_,
|
|
||||||
renameSync,
|
|
||||||
stat: stat_,
|
|
||||||
statSync,
|
|
||||||
lstat: lstat_,
|
|
||||||
lstatSync,
|
|
||||||
symlink: symlink_,
|
|
||||||
symlinkSync,
|
|
||||||
readlink: readlink_,
|
|
||||||
readlinkSync
|
|
||||||
} = require('fs')
|
|
||||||
|
|
||||||
const access = promisify(access_)
|
|
||||||
const copyFile = promisify(copyFile_)
|
|
||||||
const unlink = promisify(unlink_)
|
|
||||||
const readdir = promisify(readdir_)
|
|
||||||
const rename = promisify(rename_)
|
|
||||||
const stat = promisify(stat_)
|
|
||||||
const lstat = promisify(lstat_)
|
|
||||||
const symlink = promisify(symlink_)
|
|
||||||
const readlink = promisify(readlink_)
|
|
||||||
const rimraf = promisify(rimraf_)
|
|
||||||
const rimrafSync = rimraf_.sync
|
|
||||||
|
|
||||||
const mkdirp = require('mkdirp')
|
|
||||||
|
|
||||||
const pathExists = async path => {
|
|
||||||
try {
|
|
||||||
await access(path)
|
|
||||||
return true
|
|
||||||
} catch (er) {
|
|
||||||
return er.code !== 'ENOENT'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathExistsSync = path => {
|
|
||||||
try {
|
|
||||||
accessSync(path)
|
|
||||||
return true
|
|
||||||
} catch (er) {
|
|
||||||
return er.code !== 'ENOENT'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveFile = async (source, destination, options = {}, root = true, symlinks = []) => {
|
|
||||||
if (!source || !destination) {
|
|
||||||
throw new TypeError('`source` and `destination` file required')
|
|
||||||
}
|
|
||||||
|
|
||||||
options = {
|
|
||||||
overwrite: true,
|
|
||||||
...options
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.overwrite && await pathExists(destination)) {
|
|
||||||
throw new Error(`The destination file exists: ${destination}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdirp(dirname(destination))
|
|
||||||
|
|
||||||
try {
|
|
||||||
await rename(source, destination)
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'EXDEV' || error.code === 'EPERM') {
|
|
||||||
const sourceStat = await lstat(source)
|
|
||||||
if (sourceStat.isDirectory()) {
|
|
||||||
const files = await readdir(source)
|
|
||||||
await Promise.all(files.map((file) => moveFile(join(source, file), join(destination, file), options, false, symlinks)))
|
|
||||||
} else if (sourceStat.isSymbolicLink()) {
|
|
||||||
symlinks.push({ source, destination })
|
|
||||||
} else {
|
|
||||||
await copyFile(source, destination)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root) {
|
|
||||||
await Promise.all(symlinks.map(async ({ source, destination }) => {
|
|
||||||
let target = await readlink(source)
|
|
||||||
// junction symlinks in windows will be absolute paths, so we need to make sure they point to the destination
|
|
||||||
if (isAbsolute(target))
|
|
||||||
target = resolve(destination, relative(source, target))
|
|
||||||
// try to determine what the actual file is so we can create the correct type of symlink in windows
|
|
||||||
let targetStat
|
|
||||||
try {
|
|
||||||
targetStat = await stat(resolve(dirname(source), target))
|
|
||||||
} catch (err) {}
|
|
||||||
await symlink(target, destination, targetStat && targetStat.isDirectory() ? 'junction' : 'file')
|
|
||||||
}))
|
|
||||||
await rimraf(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveFileSync = (source, destination, options = {}, root = true, symlinks = []) => {
|
|
||||||
if (!source || !destination) {
|
|
||||||
throw new TypeError('`source` and `destination` file required')
|
|
||||||
}
|
|
||||||
|
|
||||||
options = {
|
|
||||||
overwrite: true,
|
|
||||||
...options
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.overwrite && pathExistsSync(destination)) {
|
|
||||||
throw new Error(`The destination file exists: ${destination}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirp.sync(dirname(destination))
|
|
||||||
|
|
||||||
try {
|
|
||||||
renameSync(source, destination)
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === 'EXDEV' || error.code === 'EPERM') {
|
|
||||||
const sourceStat = lstatSync(source)
|
|
||||||
if (sourceStat.isDirectory()) {
|
|
||||||
const files = readdirSync(source)
|
|
||||||
for (const file of files) {
|
|
||||||
moveFileSync(join(source, file), join(destination, file), options, false, symlinks)
|
|
||||||
}
|
|
||||||
} else if (sourceStat.isSymbolicLink()) {
|
|
||||||
symlinks.push({ source, destination })
|
|
||||||
} else {
|
|
||||||
copyFileSync(source, destination)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root) {
|
|
||||||
for (const { source, destination } of symlinks) {
|
|
||||||
let target = readlinkSync(source)
|
|
||||||
// junction symlinks in windows will be absolute paths, so we need to make sure they point to the destination
|
|
||||||
if (isAbsolute(target))
|
|
||||||
target = resolve(destination, relative(source, target))
|
|
||||||
// try to determine what the actual file is so we can create the correct type of symlink in windows
|
|
||||||
let targetStat
|
|
||||||
try {
|
|
||||||
targetStat = statSync(resolve(dirname(source), target))
|
|
||||||
} catch (err) {}
|
|
||||||
symlinkSync(target, destination, targetStat && targetStat.isDirectory() ? 'junction' : 'file')
|
|
||||||
}
|
|
||||||
rimrafSync(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = moveFile
|
|
||||||
module.exports.sync = moveFileSync
|
|
||||||
1
server/node_modules/@npmcli/move-file/node_modules/.bin/mkdirp
generated
vendored
1
server/node_modules/@npmcli/move-file/node_modules/.bin/mkdirp
generated
vendored
@@ -1 +0,0 @@
|
|||||||
../mkdirp/bin/cmd.js
|
|
||||||
15
server/node_modules/@npmcli/move-file/node_modules/mkdirp/CHANGELOG.md
generated
vendored
15
server/node_modules/@npmcli/move-file/node_modules/mkdirp/CHANGELOG.md
generated
vendored
@@ -1,15 +0,0 @@
|
|||||||
# Changers Lorgs!
|
|
||||||
|
|
||||||
## 1.0
|
|
||||||
|
|
||||||
Full rewrite. Essentially a brand new module.
|
|
||||||
|
|
||||||
- Return a promise instead of taking a callback.
|
|
||||||
- Use native `fs.mkdir(path, { recursive: true })` when available.
|
|
||||||
- Drop support for outdated Node.js versions. (Technically still works on
|
|
||||||
Node.js v8, but only 10 and above are officially supported.)
|
|
||||||
|
|
||||||
## 0.x
|
|
||||||
|
|
||||||
Original and most widely used recursive directory creation implementation
|
|
||||||
in JavaScript, dating back to 2010.
|
|
||||||
21
server/node_modules/@npmcli/move-file/node_modules/mkdirp/LICENSE
generated
vendored
21
server/node_modules/@npmcli/move-file/node_modules/mkdirp/LICENSE
generated
vendored
@@ -1,21 +0,0 @@
|
|||||||
Copyright James Halliday (mail@substack.net) and Isaac Z. Schlueter (i@izs.me)
|
|
||||||
|
|
||||||
This project is free software released under the MIT license:
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
68
server/node_modules/@npmcli/move-file/node_modules/mkdirp/bin/cmd.js
generated
vendored
68
server/node_modules/@npmcli/move-file/node_modules/mkdirp/bin/cmd.js
generated
vendored
@@ -1,68 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
const usage = () => `
|
|
||||||
usage: mkdirp [DIR1,DIR2..] {OPTIONS}
|
|
||||||
|
|
||||||
Create each supplied directory including any necessary parent directories
|
|
||||||
that don't yet exist.
|
|
||||||
|
|
||||||
If the directory already exists, do nothing.
|
|
||||||
|
|
||||||
OPTIONS are:
|
|
||||||
|
|
||||||
-m<mode> If a directory needs to be created, set the mode as an octal
|
|
||||||
--mode=<mode> permission string.
|
|
||||||
|
|
||||||
-v --version Print the mkdirp version number
|
|
||||||
|
|
||||||
-h --help Print this helpful banner
|
|
||||||
|
|
||||||
-p --print Print the first directories created for each path provided
|
|
||||||
|
|
||||||
--manual Use manual implementation, even if native is available
|
|
||||||
`
|
|
||||||
|
|
||||||
const dirs = []
|
|
||||||
const opts = {}
|
|
||||||
let print = false
|
|
||||||
let dashdash = false
|
|
||||||
let manual = false
|
|
||||||
for (const arg of process.argv.slice(2)) {
|
|
||||||
if (dashdash)
|
|
||||||
dirs.push(arg)
|
|
||||||
else if (arg === '--')
|
|
||||||
dashdash = true
|
|
||||||
else if (arg === '--manual')
|
|
||||||
manual = true
|
|
||||||
else if (/^-h/.test(arg) || /^--help/.test(arg)) {
|
|
||||||
console.log(usage())
|
|
||||||
process.exit(0)
|
|
||||||
} else if (arg === '-v' || arg === '--version') {
|
|
||||||
console.log(require('../package.json').version)
|
|
||||||
process.exit(0)
|
|
||||||
} else if (arg === '-p' || arg === '--print') {
|
|
||||||
print = true
|
|
||||||
} else if (/^-m/.test(arg) || /^--mode=/.test(arg)) {
|
|
||||||
const mode = parseInt(arg.replace(/^(-m|--mode=)/, ''), 8)
|
|
||||||
if (isNaN(mode)) {
|
|
||||||
console.error(`invalid mode argument: ${arg}\nMust be an octal number.`)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
opts.mode = mode
|
|
||||||
} else
|
|
||||||
dirs.push(arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mkdirp = require('../')
|
|
||||||
const impl = manual ? mkdirp.manual : mkdirp
|
|
||||||
if (dirs.length === 0)
|
|
||||||
console.error(usage())
|
|
||||||
|
|
||||||
Promise.all(dirs.map(dir => impl(dir, opts)))
|
|
||||||
.then(made => print ? made.forEach(m => m && console.log(m)) : null)
|
|
||||||
.catch(er => {
|
|
||||||
console.error(er.message)
|
|
||||||
if (er.code)
|
|
||||||
console.error(' code: ' + er.code)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
31
server/node_modules/@npmcli/move-file/node_modules/mkdirp/index.js
generated
vendored
31
server/node_modules/@npmcli/move-file/node_modules/mkdirp/index.js
generated
vendored
@@ -1,31 +0,0 @@
|
|||||||
const optsArg = require('./lib/opts-arg.js')
|
|
||||||
const pathArg = require('./lib/path-arg.js')
|
|
||||||
|
|
||||||
const {mkdirpNative, mkdirpNativeSync} = require('./lib/mkdirp-native.js')
|
|
||||||
const {mkdirpManual, mkdirpManualSync} = require('./lib/mkdirp-manual.js')
|
|
||||||
const {useNative, useNativeSync} = require('./lib/use-native.js')
|
|
||||||
|
|
||||||
|
|
||||||
const mkdirp = (path, opts) => {
|
|
||||||
path = pathArg(path)
|
|
||||||
opts = optsArg(opts)
|
|
||||||
return useNative(opts)
|
|
||||||
? mkdirpNative(path, opts)
|
|
||||||
: mkdirpManual(path, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mkdirpSync = (path, opts) => {
|
|
||||||
path = pathArg(path)
|
|
||||||
opts = optsArg(opts)
|
|
||||||
return useNativeSync(opts)
|
|
||||||
? mkdirpNativeSync(path, opts)
|
|
||||||
: mkdirpManualSync(path, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirp.sync = mkdirpSync
|
|
||||||
mkdirp.native = (path, opts) => mkdirpNative(pathArg(path), optsArg(opts))
|
|
||||||
mkdirp.manual = (path, opts) => mkdirpManual(pathArg(path), optsArg(opts))
|
|
||||||
mkdirp.nativeSync = (path, opts) => mkdirpNativeSync(pathArg(path), optsArg(opts))
|
|
||||||
mkdirp.manualSync = (path, opts) => mkdirpManualSync(pathArg(path), optsArg(opts))
|
|
||||||
|
|
||||||
module.exports = mkdirp
|
|
||||||
29
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/find-made.js
generated
vendored
29
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/find-made.js
generated
vendored
@@ -1,29 +0,0 @@
|
|||||||
const {dirname} = require('path')
|
|
||||||
|
|
||||||
const findMade = (opts, parent, path = undefined) => {
|
|
||||||
// we never want the 'made' return value to be a root directory
|
|
||||||
if (path === parent)
|
|
||||||
return Promise.resolve()
|
|
||||||
|
|
||||||
return opts.statAsync(parent).then(
|
|
||||||
st => st.isDirectory() ? path : undefined, // will fail later
|
|
||||||
er => er.code === 'ENOENT'
|
|
||||||
? findMade(opts, dirname(parent), parent)
|
|
||||||
: undefined
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const findMadeSync = (opts, parent, path = undefined) => {
|
|
||||||
if (path === parent)
|
|
||||||
return undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
return opts.statSync(parent).isDirectory() ? path : undefined
|
|
||||||
} catch (er) {
|
|
||||||
return er.code === 'ENOENT'
|
|
||||||
? findMadeSync(opts, dirname(parent), parent)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {findMade, findMadeSync}
|
|
||||||
64
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/mkdirp-manual.js
generated
vendored
64
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/mkdirp-manual.js
generated
vendored
@@ -1,64 +0,0 @@
|
|||||||
const {dirname} = require('path')
|
|
||||||
|
|
||||||
const mkdirpManual = (path, opts, made) => {
|
|
||||||
opts.recursive = false
|
|
||||||
const parent = dirname(path)
|
|
||||||
if (parent === path) {
|
|
||||||
return opts.mkdirAsync(path, opts).catch(er => {
|
|
||||||
// swallowed by recursive implementation on posix systems
|
|
||||||
// any other error is a failure
|
|
||||||
if (er.code !== 'EISDIR')
|
|
||||||
throw er
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return opts.mkdirAsync(path, opts).then(() => made || path, er => {
|
|
||||||
if (er.code === 'ENOENT')
|
|
||||||
return mkdirpManual(parent, opts)
|
|
||||||
.then(made => mkdirpManual(path, opts, made))
|
|
||||||
if (er.code !== 'EEXIST' && er.code !== 'EROFS')
|
|
||||||
throw er
|
|
||||||
return opts.statAsync(path).then(st => {
|
|
||||||
if (st.isDirectory())
|
|
||||||
return made
|
|
||||||
else
|
|
||||||
throw er
|
|
||||||
}, () => { throw er })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const mkdirpManualSync = (path, opts, made) => {
|
|
||||||
const parent = dirname(path)
|
|
||||||
opts.recursive = false
|
|
||||||
|
|
||||||
if (parent === path) {
|
|
||||||
try {
|
|
||||||
return opts.mkdirSync(path, opts)
|
|
||||||
} catch (er) {
|
|
||||||
// swallowed by recursive implementation on posix systems
|
|
||||||
// any other error is a failure
|
|
||||||
if (er.code !== 'EISDIR')
|
|
||||||
throw er
|
|
||||||
else
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
opts.mkdirSync(path, opts)
|
|
||||||
return made || path
|
|
||||||
} catch (er) {
|
|
||||||
if (er.code === 'ENOENT')
|
|
||||||
return mkdirpManualSync(path, opts, mkdirpManualSync(parent, opts, made))
|
|
||||||
if (er.code !== 'EEXIST' && er.code !== 'EROFS')
|
|
||||||
throw er
|
|
||||||
try {
|
|
||||||
if (!opts.statSync(path).isDirectory())
|
|
||||||
throw er
|
|
||||||
} catch (_) {
|
|
||||||
throw er
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {mkdirpManual, mkdirpManualSync}
|
|
||||||
39
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/mkdirp-native.js
generated
vendored
39
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/mkdirp-native.js
generated
vendored
@@ -1,39 +0,0 @@
|
|||||||
const {dirname} = require('path')
|
|
||||||
const {findMade, findMadeSync} = require('./find-made.js')
|
|
||||||
const {mkdirpManual, mkdirpManualSync} = require('./mkdirp-manual.js')
|
|
||||||
|
|
||||||
const mkdirpNative = (path, opts) => {
|
|
||||||
opts.recursive = true
|
|
||||||
const parent = dirname(path)
|
|
||||||
if (parent === path)
|
|
||||||
return opts.mkdirAsync(path, opts)
|
|
||||||
|
|
||||||
return findMade(opts, path).then(made =>
|
|
||||||
opts.mkdirAsync(path, opts).then(() => made)
|
|
||||||
.catch(er => {
|
|
||||||
if (er.code === 'ENOENT')
|
|
||||||
return mkdirpManual(path, opts)
|
|
||||||
else
|
|
||||||
throw er
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const mkdirpNativeSync = (path, opts) => {
|
|
||||||
opts.recursive = true
|
|
||||||
const parent = dirname(path)
|
|
||||||
if (parent === path)
|
|
||||||
return opts.mkdirSync(path, opts)
|
|
||||||
|
|
||||||
const made = findMadeSync(opts, path)
|
|
||||||
try {
|
|
||||||
opts.mkdirSync(path, opts)
|
|
||||||
return made
|
|
||||||
} catch (er) {
|
|
||||||
if (er.code === 'ENOENT')
|
|
||||||
return mkdirpManualSync(path, opts)
|
|
||||||
else
|
|
||||||
throw er
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {mkdirpNative, mkdirpNativeSync}
|
|
||||||
23
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/opts-arg.js
generated
vendored
23
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/opts-arg.js
generated
vendored
@@ -1,23 +0,0 @@
|
|||||||
const { promisify } = require('util')
|
|
||||||
const fs = require('fs')
|
|
||||||
const optsArg = opts => {
|
|
||||||
if (!opts)
|
|
||||||
opts = { mode: 0o777, fs }
|
|
||||||
else if (typeof opts === 'object')
|
|
||||||
opts = { mode: 0o777, fs, ...opts }
|
|
||||||
else if (typeof opts === 'number')
|
|
||||||
opts = { mode: opts, fs }
|
|
||||||
else if (typeof opts === 'string')
|
|
||||||
opts = { mode: parseInt(opts, 8), fs }
|
|
||||||
else
|
|
||||||
throw new TypeError('invalid options argument')
|
|
||||||
|
|
||||||
opts.mkdir = opts.mkdir || opts.fs.mkdir || fs.mkdir
|
|
||||||
opts.mkdirAsync = promisify(opts.mkdir)
|
|
||||||
opts.stat = opts.stat || opts.fs.stat || fs.stat
|
|
||||||
opts.statAsync = promisify(opts.stat)
|
|
||||||
opts.statSync = opts.statSync || opts.fs.statSync || fs.statSync
|
|
||||||
opts.mkdirSync = opts.mkdirSync || opts.fs.mkdirSync || fs.mkdirSync
|
|
||||||
return opts
|
|
||||||
}
|
|
||||||
module.exports = optsArg
|
|
||||||
29
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/path-arg.js
generated
vendored
29
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/path-arg.js
generated
vendored
@@ -1,29 +0,0 @@
|
|||||||
const platform = process.env.__TESTING_MKDIRP_PLATFORM__ || process.platform
|
|
||||||
const { resolve, parse } = require('path')
|
|
||||||
const pathArg = path => {
|
|
||||||
if (/\0/.test(path)) {
|
|
||||||
// simulate same failure that node raises
|
|
||||||
throw Object.assign(
|
|
||||||
new TypeError('path must be a string without null bytes'),
|
|
||||||
{
|
|
||||||
path,
|
|
||||||
code: 'ERR_INVALID_ARG_VALUE',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
path = resolve(path)
|
|
||||||
if (platform === 'win32') {
|
|
||||||
const badWinChars = /[*|"<>?:]/
|
|
||||||
const {root} = parse(path)
|
|
||||||
if (badWinChars.test(path.substr(root.length))) {
|
|
||||||
throw Object.assign(new Error('Illegal characters in path.'), {
|
|
||||||
path,
|
|
||||||
code: 'EINVAL',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
module.exports = pathArg
|
|
||||||
10
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/use-native.js
generated
vendored
10
server/node_modules/@npmcli/move-file/node_modules/mkdirp/lib/use-native.js
generated
vendored
@@ -1,10 +0,0 @@
|
|||||||
const fs = require('fs')
|
|
||||||
|
|
||||||
const version = process.env.__TESTING_MKDIRP_NODE_VERSION__ || process.version
|
|
||||||
const versArr = version.replace(/^v/, '').split('.')
|
|
||||||
const hasNative = +versArr[0] > 10 || +versArr[0] === 10 && +versArr[1] >= 12
|
|
||||||
|
|
||||||
const useNative = !hasNative ? () => false : opts => opts.mkdir === fs.mkdir
|
|
||||||
const useNativeSync = !hasNative ? () => false : opts => opts.mkdirSync === fs.mkdirSync
|
|
||||||
|
|
||||||
module.exports = {useNative, useNativeSync}
|
|
||||||
44
server/node_modules/@npmcli/move-file/node_modules/mkdirp/package.json
generated
vendored
44
server/node_modules/@npmcli/move-file/node_modules/mkdirp/package.json
generated
vendored
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mkdirp",
|
|
||||||
"description": "Recursively mkdir, like `mkdir -p`",
|
|
||||||
"version": "1.0.4",
|
|
||||||
"main": "index.js",
|
|
||||||
"keywords": [
|
|
||||||
"mkdir",
|
|
||||||
"directory",
|
|
||||||
"make dir",
|
|
||||||
"make",
|
|
||||||
"dir",
|
|
||||||
"recursive",
|
|
||||||
"native"
|
|
||||||
],
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://github.com/isaacs/node-mkdirp.git"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "tap",
|
|
||||||
"snap": "tap",
|
|
||||||
"preversion": "npm test",
|
|
||||||
"postversion": "npm publish",
|
|
||||||
"postpublish": "git push origin --follow-tags"
|
|
||||||
},
|
|
||||||
"tap": {
|
|
||||||
"check-coverage": true,
|
|
||||||
"coverage-map": "map.js"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"require-inject": "^1.4.4",
|
|
||||||
"tap": "^14.10.7"
|
|
||||||
},
|
|
||||||
"bin": "bin/cmd.js",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"bin",
|
|
||||||
"lib",
|
|
||||||
"index.js"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
266
server/node_modules/@npmcli/move-file/node_modules/mkdirp/readme.markdown
generated
vendored
266
server/node_modules/@npmcli/move-file/node_modules/mkdirp/readme.markdown
generated
vendored
@@ -1,266 +0,0 @@
|
|||||||
# mkdirp
|
|
||||||
|
|
||||||
Like `mkdir -p`, but in Node.js!
|
|
||||||
|
|
||||||
Now with a modern API and no\* bugs!
|
|
||||||
|
|
||||||
<small>\* may contain some bugs</small>
|
|
||||||
|
|
||||||
# example
|
|
||||||
|
|
||||||
## pow.js
|
|
||||||
|
|
||||||
```js
|
|
||||||
const mkdirp = require('mkdirp')
|
|
||||||
|
|
||||||
// return value is a Promise resolving to the first directory created
|
|
||||||
mkdirp('/tmp/foo/bar/baz').then(made =>
|
|
||||||
console.log(`made directories, starting with ${made}`))
|
|
||||||
```
|
|
||||||
|
|
||||||
Output (where `/tmp/foo` already exists)
|
|
||||||
|
|
||||||
```
|
|
||||||
made directories, starting with /tmp/foo/bar
|
|
||||||
```
|
|
||||||
|
|
||||||
Or, if you don't have time to wait around for promises:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const mkdirp = require('mkdirp')
|
|
||||||
|
|
||||||
// return value is the first directory created
|
|
||||||
const made = mkdirp.sync('/tmp/foo/bar/baz')
|
|
||||||
console.log(`made directories, starting with ${made}`)
|
|
||||||
```
|
|
||||||
|
|
||||||
And now /tmp/foo/bar/baz exists, huzzah!
|
|
||||||
|
|
||||||
# methods
|
|
||||||
|
|
||||||
```js
|
|
||||||
const mkdirp = require('mkdirp')
|
|
||||||
```
|
|
||||||
|
|
||||||
## mkdirp(dir, [opts]) -> Promise<String | undefined>
|
|
||||||
|
|
||||||
Create a new directory and any necessary subdirectories at `dir` with octal
|
|
||||||
permission string `opts.mode`. If `opts` is a string or number, it will be
|
|
||||||
treated as the `opts.mode`.
|
|
||||||
|
|
||||||
If `opts.mode` isn't specified, it defaults to `0o777 &
|
|
||||||
(~process.umask())`.
|
|
||||||
|
|
||||||
Promise resolves to first directory `made` that had to be created, or
|
|
||||||
`undefined` if everything already exists. Promise rejects if any errors
|
|
||||||
are encountered. Note that, in the case of promise rejection, some
|
|
||||||
directories _may_ have been created, as recursive directory creation is not
|
|
||||||
an atomic operation.
|
|
||||||
|
|
||||||
You can optionally pass in an alternate `fs` implementation by passing in
|
|
||||||
`opts.fs`. Your implementation should have `opts.fs.mkdir(path, opts, cb)`
|
|
||||||
and `opts.fs.stat(path, cb)`.
|
|
||||||
|
|
||||||
You can also override just one or the other of `mkdir` and `stat` by
|
|
||||||
passing in `opts.stat` or `opts.mkdir`, or providing an `fs` option that
|
|
||||||
only overrides one of these.
|
|
||||||
|
|
||||||
## mkdirp.sync(dir, opts) -> String|null
|
|
||||||
|
|
||||||
Synchronously create a new directory and any necessary subdirectories at
|
|
||||||
`dir` with octal permission string `opts.mode`. If `opts` is a string or
|
|
||||||
number, it will be treated as the `opts.mode`.
|
|
||||||
|
|
||||||
If `opts.mode` isn't specified, it defaults to `0o777 &
|
|
||||||
(~process.umask())`.
|
|
||||||
|
|
||||||
Returns the first directory that had to be created, or undefined if
|
|
||||||
everything already exists.
|
|
||||||
|
|
||||||
You can optionally pass in an alternate `fs` implementation by passing in
|
|
||||||
`opts.fs`. Your implementation should have `opts.fs.mkdirSync(path, mode)`
|
|
||||||
and `opts.fs.statSync(path)`.
|
|
||||||
|
|
||||||
You can also override just one or the other of `mkdirSync` and `statSync`
|
|
||||||
by passing in `opts.statSync` or `opts.mkdirSync`, or providing an `fs`
|
|
||||||
option that only overrides one of these.
|
|
||||||
|
|
||||||
## mkdirp.manual, mkdirp.manualSync
|
|
||||||
|
|
||||||
Use the manual implementation (not the native one). This is the default
|
|
||||||
when the native implementation is not available or the stat/mkdir
|
|
||||||
implementation is overridden.
|
|
||||||
|
|
||||||
## mkdirp.native, mkdirp.nativeSync
|
|
||||||
|
|
||||||
Use the native implementation (not the manual one). This is the default
|
|
||||||
when the native implementation is available and stat/mkdir are not
|
|
||||||
overridden.
|
|
||||||
|
|
||||||
# implementation
|
|
||||||
|
|
||||||
On Node.js v10.12.0 and above, use the native `fs.mkdir(p,
|
|
||||||
{recursive:true})` option, unless `fs.mkdir`/`fs.mkdirSync` has been
|
|
||||||
overridden by an option.
|
|
||||||
|
|
||||||
## native implementation
|
|
||||||
|
|
||||||
- If the path is a root directory, then pass it to the underlying
|
|
||||||
implementation and return the result/error. (In this case, it'll either
|
|
||||||
succeed or fail, but we aren't actually creating any dirs.)
|
|
||||||
- Walk up the path statting each directory, to find the first path that
|
|
||||||
will be created, `made`.
|
|
||||||
- Call `fs.mkdir(path, { recursive: true })` (or `fs.mkdirSync`)
|
|
||||||
- If error, raise it to the caller.
|
|
||||||
- Return `made`.
|
|
||||||
|
|
||||||
## manual implementation
|
|
||||||
|
|
||||||
- Call underlying `fs.mkdir` implementation, with `recursive: false`
|
|
||||||
- If error:
|
|
||||||
- If path is a root directory, raise to the caller and do not handle it
|
|
||||||
- If ENOENT, mkdirp parent dir, store result as `made`
|
|
||||||
- stat(path)
|
|
||||||
- If error, raise original `mkdir` error
|
|
||||||
- If directory, return `made`
|
|
||||||
- Else, raise original `mkdir` error
|
|
||||||
- else
|
|
||||||
- return `undefined` if a root dir, or `made` if set, or `path`
|
|
||||||
|
|
||||||
## windows vs unix caveat
|
|
||||||
|
|
||||||
On Windows file systems, attempts to create a root directory (ie, a drive
|
|
||||||
letter or root UNC path) will fail. If the root directory exists, then it
|
|
||||||
will fail with `EPERM`. If the root directory does not exist, then it will
|
|
||||||
fail with `ENOENT`.
|
|
||||||
|
|
||||||
On posix file systems, attempts to create a root directory (in recursive
|
|
||||||
mode) will succeed silently, as it is treated like just another directory
|
|
||||||
that already exists. (In non-recursive mode, of course, it fails with
|
|
||||||
`EEXIST`.)
|
|
||||||
|
|
||||||
In order to preserve this system-specific behavior (and because it's not as
|
|
||||||
if we can create the parent of a root directory anyway), attempts to create
|
|
||||||
a root directory are passed directly to the `fs` implementation, and any
|
|
||||||
errors encountered are not handled.
|
|
||||||
|
|
||||||
## native error caveat
|
|
||||||
|
|
||||||
The native implementation (as of at least Node.js v13.4.0) does not provide
|
|
||||||
appropriate errors in some cases (see
|
|
||||||
[nodejs/node#31481](https://github.com/nodejs/node/issues/31481) and
|
|
||||||
[nodejs/node#28015](https://github.com/nodejs/node/issues/28015)).
|
|
||||||
|
|
||||||
In order to work around this issue, the native implementation will fall
|
|
||||||
back to the manual implementation if an `ENOENT` error is encountered.
|
|
||||||
|
|
||||||
# choosing a recursive mkdir implementation
|
|
||||||
|
|
||||||
There are a few to choose from! Use the one that suits your needs best :D
|
|
||||||
|
|
||||||
## use `fs.mkdir(path, {recursive: true}, cb)` if:
|
|
||||||
|
|
||||||
- You wish to optimize performance even at the expense of other factors.
|
|
||||||
- You don't need to know the first dir created.
|
|
||||||
- You are ok with getting `ENOENT` as the error when some other problem is
|
|
||||||
the actual cause.
|
|
||||||
- You can limit your platforms to Node.js v10.12 and above.
|
|
||||||
- You're ok with using callbacks instead of promises.
|
|
||||||
- You don't need/want a CLI.
|
|
||||||
- You don't need to override the `fs` methods in use.
|
|
||||||
|
|
||||||
## use this module (mkdirp 1.x) if:
|
|
||||||
|
|
||||||
- You need to know the first directory that was created.
|
|
||||||
- You wish to use the native implementation if available, but fall back
|
|
||||||
when it's not.
|
|
||||||
- You prefer promise-returning APIs to callback-taking APIs.
|
|
||||||
- You want more useful error messages than the native recursive mkdir
|
|
||||||
provides (at least as of Node.js v13.4), and are ok with re-trying on
|
|
||||||
`ENOENT` to achieve this.
|
|
||||||
- You need (or at least, are ok with) a CLI.
|
|
||||||
- You need to override the `fs` methods in use.
|
|
||||||
|
|
||||||
## use [`make-dir`](http://npm.im/make-dir) if:
|
|
||||||
|
|
||||||
- You do not need to know the first dir created (and wish to save a few
|
|
||||||
`stat` calls when using the native implementation for this reason).
|
|
||||||
- You wish to use the native implementation if available, but fall back
|
|
||||||
when it's not.
|
|
||||||
- You prefer promise-returning APIs to callback-taking APIs.
|
|
||||||
- You are ok with occasionally getting `ENOENT` errors for failures that
|
|
||||||
are actually related to something other than a missing file system entry.
|
|
||||||
- You don't need/want a CLI.
|
|
||||||
- You need to override the `fs` methods in use.
|
|
||||||
|
|
||||||
## use mkdirp 0.x if:
|
|
||||||
|
|
||||||
- You need to know the first directory that was created.
|
|
||||||
- You need (or at least, are ok with) a CLI.
|
|
||||||
- You need to override the `fs` methods in use.
|
|
||||||
- You're ok with using callbacks instead of promises.
|
|
||||||
- You are not running on Windows, where the root-level ENOENT errors can
|
|
||||||
lead to infinite regress.
|
|
||||||
- You think vinyl just sounds warmer and richer for some weird reason.
|
|
||||||
- You are supporting truly ancient Node.js versions, before even the advent
|
|
||||||
of a `Promise` language primitive. (Please don't. You deserve better.)
|
|
||||||
|
|
||||||
# cli
|
|
||||||
|
|
||||||
This package also ships with a `mkdirp` command.
|
|
||||||
|
|
||||||
```
|
|
||||||
$ mkdirp -h
|
|
||||||
|
|
||||||
usage: mkdirp [DIR1,DIR2..] {OPTIONS}
|
|
||||||
|
|
||||||
Create each supplied directory including any necessary parent directories
|
|
||||||
that don't yet exist.
|
|
||||||
|
|
||||||
If the directory already exists, do nothing.
|
|
||||||
|
|
||||||
OPTIONS are:
|
|
||||||
|
|
||||||
-m<mode> If a directory needs to be created, set the mode as an octal
|
|
||||||
--mode=<mode> permission string.
|
|
||||||
|
|
||||||
-v --version Print the mkdirp version number
|
|
||||||
|
|
||||||
-h --help Print this helpful banner
|
|
||||||
|
|
||||||
-p --print Print the first directories created for each path provided
|
|
||||||
|
|
||||||
--manual Use manual implementation, even if native is available
|
|
||||||
```
|
|
||||||
|
|
||||||
# install
|
|
||||||
|
|
||||||
With [npm](http://npmjs.org) do:
|
|
||||||
|
|
||||||
```
|
|
||||||
npm install mkdirp
|
|
||||||
```
|
|
||||||
|
|
||||||
to get the library locally, or
|
|
||||||
|
|
||||||
```
|
|
||||||
npm install -g mkdirp
|
|
||||||
```
|
|
||||||
|
|
||||||
to get the command everywhere, or
|
|
||||||
|
|
||||||
```
|
|
||||||
npx mkdirp ...
|
|
||||||
```
|
|
||||||
|
|
||||||
to run the command without installing it globally.
|
|
||||||
|
|
||||||
# platform support
|
|
||||||
|
|
||||||
This module works on node v8, but only v10 and above are officially
|
|
||||||
supported, as Node v8 reached its LTS end of life 2020-01-01, which is in
|
|
||||||
the past, as of this writing.
|
|
||||||
|
|
||||||
# license
|
|
||||||
|
|
||||||
MIT
|
|
||||||
34
server/node_modules/@npmcli/move-file/package.json
generated
vendored
34
server/node_modules/@npmcli/move-file/package.json
generated
vendored
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@npmcli/move-file",
|
|
||||||
"version": "1.1.2",
|
|
||||||
"files": [
|
|
||||||
"index.js"
|
|
||||||
],
|
|
||||||
"description": "move a file (fork of move-file)",
|
|
||||||
"dependencies": {
|
|
||||||
"mkdirp": "^1.0.4",
|
|
||||||
"rimraf": "^3.0.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"require-inject": "^1.4.4",
|
|
||||||
"tap": "^14.10.7"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "tap",
|
|
||||||
"snap": "tap",
|
|
||||||
"preversion": "npm test",
|
|
||||||
"postversion": "npm publish",
|
|
||||||
"prepublishOnly": "git push origin --follow-tags"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git+https://github.com/npm/move-file"
|
|
||||||
},
|
|
||||||
"tap": {
|
|
||||||
"check-coverage": true
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
server/node_modules/@tootallnate/once/dist/index.d.ts
generated
vendored
14
server/node_modules/@tootallnate/once/dist/index.d.ts
generated
vendored
@@ -1,14 +0,0 @@
|
|||||||
/// <reference types="node" />
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
declare function once<T>(emitter: EventEmitter, name: string): once.CancelablePromise<T>;
|
|
||||||
declare namespace once {
|
|
||||||
interface CancelFunction {
|
|
||||||
(): void;
|
|
||||||
}
|
|
||||||
interface CancelablePromise<T> extends Promise<T> {
|
|
||||||
cancel: CancelFunction;
|
|
||||||
}
|
|
||||||
type CancellablePromise<T> = CancelablePromise<T>;
|
|
||||||
function spread<T extends any[]>(emitter: EventEmitter, name: string): once.CancelablePromise<T>;
|
|
||||||
}
|
|
||||||
export = once;
|
|
||||||
39
server/node_modules/@tootallnate/once/dist/index.js
generated
vendored
39
server/node_modules/@tootallnate/once/dist/index.js
generated
vendored
@@ -1,39 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
function noop() { }
|
|
||||||
function once(emitter, name) {
|
|
||||||
const o = once.spread(emitter, name);
|
|
||||||
const r = o.then((args) => args[0]);
|
|
||||||
r.cancel = o.cancel;
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
(function (once) {
|
|
||||||
function spread(emitter, name) {
|
|
||||||
let c = null;
|
|
||||||
const p = new Promise((resolve, reject) => {
|
|
||||||
function cancel() {
|
|
||||||
emitter.removeListener(name, onEvent);
|
|
||||||
emitter.removeListener('error', onError);
|
|
||||||
p.cancel = noop;
|
|
||||||
}
|
|
||||||
function onEvent(...args) {
|
|
||||||
cancel();
|
|
||||||
resolve(args);
|
|
||||||
}
|
|
||||||
function onError(err) {
|
|
||||||
cancel();
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
c = cancel;
|
|
||||||
emitter.on(name, onEvent);
|
|
||||||
emitter.on('error', onError);
|
|
||||||
});
|
|
||||||
if (!c) {
|
|
||||||
throw new TypeError('Could not get `cancel()` function');
|
|
||||||
}
|
|
||||||
p.cancel = c;
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
once.spread = spread;
|
|
||||||
})(once || (once = {}));
|
|
||||||
module.exports = once;
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
1
server/node_modules/@tootallnate/once/dist/index.js.map
generated
vendored
1
server/node_modules/@tootallnate/once/dist/index.js.map
generated
vendored
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAEA,SAAS,IAAI,KAAI,CAAC;AAElB,SAAS,IAAI,CACZ,OAAqB,EACrB,IAAY;IAEZ,MAAM,CAAC,GAAG,IAAI,CAAC,MAAM,CAAM,OAAO,EAAE,IAAI,CAAC,CAAC;IAC1C,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,IAAS,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,CAA8B,CAAC;IACtE,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,MAAM,CAAC;IACpB,OAAO,CAAC,CAAC;AACV,CAAC;AAED,WAAU,IAAI;IAWb,SAAgB,MAAM,CACrB,OAAqB,EACrB,IAAY;QAEZ,IAAI,CAAC,GAA+B,IAAI,CAAC;QACzC,MAAM,CAAC,GAAG,IAAI,OAAO,CAAI,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC5C,SAAS,MAAM;gBACd,OAAO,CAAC,cAAc,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;gBACtC,OAAO,CAAC,cAAc,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;gBACzC,CAAC,CAAC,MAAM,GAAG,IAAI,CAAC;YACjB,CAAC;YACD,SAAS,OAAO,CAAC,GAAG,IAAW;gBAC9B,MAAM,EAAE,CAAC;gBACT,OAAO,CAAC,IAAS,CAAC,CAAC;YACpB,CAAC;YACD,SAAS,OAAO,CAAC,GAAU;gBAC1B,MAAM,EAAE,CAAC;gBACT,MAAM,CAAC,GAAG,CAAC,CAAC;YACb,CAAC;YACD,CAAC,GAAG,MAAM,CAAC;YACX,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;YAC1B,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC9B,CAAC,CAA8B,CAAC;QAChC,IAAI,CAAC,CAAC,EAAE;YACP,MAAM,IAAI,SAAS,CAAC,mCAAmC,CAAC,CAAC;SACzD;QACD,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC;QACb,OAAO,CAAC,CAAC;IACV,CAAC;IA5Be,WAAM,SA4BrB,CAAA;AACF,CAAC,EAxCS,IAAI,KAAJ,IAAI,QAwCb;AAED,iBAAS,IAAI,CAAC"}
|
|
||||||
45
server/node_modules/@tootallnate/once/package.json
generated
vendored
45
server/node_modules/@tootallnate/once/package.json
generated
vendored
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@tootallnate/once",
|
|
||||||
"version": "1.1.2",
|
|
||||||
"description": "Creates a Promise that waits for a single event",
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"prebuild": "rimraf dist",
|
|
||||||
"build": "tsc",
|
|
||||||
"test": "mocha --reporter spec",
|
|
||||||
"test-lint": "eslint src --ext .js,.ts",
|
|
||||||
"prepublishOnly": "npm run build"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "git://github.com/TooTallNate/once.git"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "Nathan Rajlich <nathan@tootallnate.net> (http://n8.io/)",
|
|
||||||
"license": "MIT",
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/TooTallNate/once/issues"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^12.12.11",
|
|
||||||
"@typescript-eslint/eslint-plugin": "1.6.0",
|
|
||||||
"@typescript-eslint/parser": "1.1.0",
|
|
||||||
"eslint": "5.16.0",
|
|
||||||
"eslint-config-airbnb": "17.1.0",
|
|
||||||
"eslint-config-prettier": "4.1.0",
|
|
||||||
"eslint-import-resolver-typescript": "1.1.1",
|
|
||||||
"eslint-plugin-import": "2.16.0",
|
|
||||||
"eslint-plugin-jsx-a11y": "6.2.1",
|
|
||||||
"eslint-plugin-react": "7.12.4",
|
|
||||||
"mocha": "^6.2.2",
|
|
||||||
"rimraf": "^3.0.0",
|
|
||||||
"typescript": "^3.7.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
46
server/node_modules/abbrev/LICENSE
generated
vendored
46
server/node_modules/abbrev/LICENSE
generated
vendored
@@ -1,46 +0,0 @@
|
|||||||
This software is dual-licensed under the ISC and MIT licenses.
|
|
||||||
You may use this software under EITHER of the following licenses.
|
|
||||||
|
|
||||||
----------
|
|
||||||
|
|
||||||
The ISC License
|
|
||||||
|
|
||||||
Copyright (c) Isaac Z. Schlueter and Contributors
|
|
||||||
|
|
||||||
Permission to use, copy, modify, and/or distribute this software for any
|
|
||||||
purpose with or without fee is hereby granted, provided that the above
|
|
||||||
copyright notice and this permission notice appear in all copies.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
||||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
||||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
||||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
||||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
||||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
|
||||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
||||||
|
|
||||||
----------
|
|
||||||
|
|
||||||
Copyright Isaac Z. Schlueter and Contributors
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person
|
|
||||||
obtaining a copy of this software and associated documentation
|
|
||||||
files (the "Software"), to deal in the Software without
|
|
||||||
restriction, including without limitation the rights to use,
|
|
||||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the
|
|
||||||
Software is furnished to do so, subject to the following
|
|
||||||
conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
||||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
||||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
||||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
||||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
||||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
||||||
OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
23
server/node_modules/abbrev/README.md
generated
vendored
23
server/node_modules/abbrev/README.md
generated
vendored
@@ -1,23 +0,0 @@
|
|||||||
# abbrev-js
|
|
||||||
|
|
||||||
Just like [ruby's Abbrev](http://apidock.com/ruby/Abbrev).
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
|
|
||||||
var abbrev = require("abbrev");
|
|
||||||
abbrev("foo", "fool", "folding", "flop");
|
|
||||||
|
|
||||||
// returns:
|
|
||||||
{ fl: 'flop'
|
|
||||||
, flo: 'flop'
|
|
||||||
, flop: 'flop'
|
|
||||||
, fol: 'folding'
|
|
||||||
, fold: 'folding'
|
|
||||||
, foldi: 'folding'
|
|
||||||
, foldin: 'folding'
|
|
||||||
, folding: 'folding'
|
|
||||||
, foo: 'foo'
|
|
||||||
, fool: 'fool'
|
|
||||||
}
|
|
||||||
|
|
||||||
This is handy for command-line scripts, or other cases where you want to be able to accept shorthands.
|
|
||||||
61
server/node_modules/abbrev/abbrev.js
generated
vendored
61
server/node_modules/abbrev/abbrev.js
generated
vendored
@@ -1,61 +0,0 @@
|
|||||||
module.exports = exports = abbrev.abbrev = abbrev
|
|
||||||
|
|
||||||
abbrev.monkeyPatch = monkeyPatch
|
|
||||||
|
|
||||||
function monkeyPatch () {
|
|
||||||
Object.defineProperty(Array.prototype, 'abbrev', {
|
|
||||||
value: function () { return abbrev(this) },
|
|
||||||
enumerable: false, configurable: true, writable: true
|
|
||||||
})
|
|
||||||
|
|
||||||
Object.defineProperty(Object.prototype, 'abbrev', {
|
|
||||||
value: function () { return abbrev(Object.keys(this)) },
|
|
||||||
enumerable: false, configurable: true, writable: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function abbrev (list) {
|
|
||||||
if (arguments.length !== 1 || !Array.isArray(list)) {
|
|
||||||
list = Array.prototype.slice.call(arguments, 0)
|
|
||||||
}
|
|
||||||
for (var i = 0, l = list.length, args = [] ; i < l ; i ++) {
|
|
||||||
args[i] = typeof list[i] === "string" ? list[i] : String(list[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
// sort them lexicographically, so that they're next to their nearest kin
|
|
||||||
args = args.sort(lexSort)
|
|
||||||
|
|
||||||
// walk through each, seeing how much it has in common with the next and previous
|
|
||||||
var abbrevs = {}
|
|
||||||
, prev = ""
|
|
||||||
for (var i = 0, l = args.length ; i < l ; i ++) {
|
|
||||||
var current = args[i]
|
|
||||||
, next = args[i + 1] || ""
|
|
||||||
, nextMatches = true
|
|
||||||
, prevMatches = true
|
|
||||||
if (current === next) continue
|
|
||||||
for (var j = 0, cl = current.length ; j < cl ; j ++) {
|
|
||||||
var curChar = current.charAt(j)
|
|
||||||
nextMatches = nextMatches && curChar === next.charAt(j)
|
|
||||||
prevMatches = prevMatches && curChar === prev.charAt(j)
|
|
||||||
if (!nextMatches && !prevMatches) {
|
|
||||||
j ++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev = current
|
|
||||||
if (j === cl) {
|
|
||||||
abbrevs[current] = current
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for (var a = current.substr(0, j) ; j <= cl ; j ++) {
|
|
||||||
abbrevs[a] = current
|
|
||||||
a += current.charAt(j)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return abbrevs
|
|
||||||
}
|
|
||||||
|
|
||||||
function lexSort (a, b) {
|
|
||||||
return a === b ? 0 : a > b ? 1 : -1
|
|
||||||
}
|
|
||||||
21
server/node_modules/abbrev/package.json
generated
vendored
21
server/node_modules/abbrev/package.json
generated
vendored
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "abbrev",
|
|
||||||
"version": "1.1.1",
|
|
||||||
"description": "Like ruby's abbrev module, but in js",
|
|
||||||
"author": "Isaac Z. Schlueter <i@izs.me>",
|
|
||||||
"main": "abbrev.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "tap test.js --100",
|
|
||||||
"preversion": "npm test",
|
|
||||||
"postversion": "npm publish",
|
|
||||||
"postpublish": "git push origin --all; git push origin --tags"
|
|
||||||
},
|
|
||||||
"repository": "http://github.com/isaacs/abbrev-js",
|
|
||||||
"license": "ISC",
|
|
||||||
"devDependencies": {
|
|
||||||
"tap": "^10.1"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"abbrev.js"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
243
server/node_modules/accepts/HISTORY.md
generated
vendored
243
server/node_modules/accepts/HISTORY.md
generated
vendored
@@ -1,243 +0,0 @@
|
|||||||
1.3.8 / 2022-02-02
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.34
|
|
||||||
- deps: mime-db@~1.51.0
|
|
||||||
* deps: negotiator@0.6.3
|
|
||||||
|
|
||||||
1.3.7 / 2019-04-29
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.6.2
|
|
||||||
- Fix sorting charset, encoding, and language with extra parameters
|
|
||||||
|
|
||||||
1.3.6 / 2019-04-28
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.24
|
|
||||||
- deps: mime-db@~1.40.0
|
|
||||||
|
|
||||||
1.3.5 / 2018-02-28
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.18
|
|
||||||
- deps: mime-db@~1.33.0
|
|
||||||
|
|
||||||
1.3.4 / 2017-08-22
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.16
|
|
||||||
- deps: mime-db@~1.29.0
|
|
||||||
|
|
||||||
1.3.3 / 2016-05-02
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.11
|
|
||||||
- deps: mime-db@~1.23.0
|
|
||||||
* deps: negotiator@0.6.1
|
|
||||||
- perf: improve `Accept` parsing speed
|
|
||||||
- perf: improve `Accept-Charset` parsing speed
|
|
||||||
- perf: improve `Accept-Encoding` parsing speed
|
|
||||||
- perf: improve `Accept-Language` parsing speed
|
|
||||||
|
|
||||||
1.3.2 / 2016-03-08
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.10
|
|
||||||
- Fix extension of `application/dash+xml`
|
|
||||||
- Update primary extension for `audio/mp4`
|
|
||||||
- deps: mime-db@~1.22.0
|
|
||||||
|
|
||||||
1.3.1 / 2016-01-19
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.9
|
|
||||||
- deps: mime-db@~1.21.0
|
|
||||||
|
|
||||||
1.3.0 / 2015-09-29
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.7
|
|
||||||
- deps: mime-db@~1.19.0
|
|
||||||
* deps: negotiator@0.6.0
|
|
||||||
- Fix including type extensions in parameters in `Accept` parsing
|
|
||||||
- Fix parsing `Accept` parameters with quoted equals
|
|
||||||
- Fix parsing `Accept` parameters with quoted semicolons
|
|
||||||
- Lazy-load modules from main entry point
|
|
||||||
- perf: delay type concatenation until needed
|
|
||||||
- perf: enable strict mode
|
|
||||||
- perf: hoist regular expressions
|
|
||||||
- perf: remove closures getting spec properties
|
|
||||||
- perf: remove a closure from media type parsing
|
|
||||||
- perf: remove property delete from media type parsing
|
|
||||||
|
|
||||||
1.2.13 / 2015-09-06
|
|
||||||
===================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.6
|
|
||||||
- deps: mime-db@~1.18.0
|
|
||||||
|
|
||||||
1.2.12 / 2015-07-30
|
|
||||||
===================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.4
|
|
||||||
- deps: mime-db@~1.16.0
|
|
||||||
|
|
||||||
1.2.11 / 2015-07-16
|
|
||||||
===================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.3
|
|
||||||
- deps: mime-db@~1.15.0
|
|
||||||
|
|
||||||
1.2.10 / 2015-07-01
|
|
||||||
===================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.2
|
|
||||||
- deps: mime-db@~1.14.0
|
|
||||||
|
|
||||||
1.2.9 / 2015-06-08
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.1
|
|
||||||
- perf: fix deopt during mapping
|
|
||||||
|
|
||||||
1.2.8 / 2015-06-07
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.1.0
|
|
||||||
- deps: mime-db@~1.13.0
|
|
||||||
* perf: avoid argument reassignment & argument slice
|
|
||||||
* perf: avoid negotiator recursive construction
|
|
||||||
* perf: enable strict mode
|
|
||||||
* perf: remove unnecessary bitwise operator
|
|
||||||
|
|
||||||
1.2.7 / 2015-05-10
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.5.3
|
|
||||||
- Fix media type parameter matching to be case-insensitive
|
|
||||||
|
|
||||||
1.2.6 / 2015-05-07
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.11
|
|
||||||
- deps: mime-db@~1.9.1
|
|
||||||
* deps: negotiator@0.5.2
|
|
||||||
- Fix comparing media types with quoted values
|
|
||||||
- Fix splitting media types with quoted commas
|
|
||||||
|
|
||||||
1.2.5 / 2015-03-13
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.10
|
|
||||||
- deps: mime-db@~1.8.0
|
|
||||||
|
|
||||||
1.2.4 / 2015-02-14
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Support Node.js 0.6
|
|
||||||
* deps: mime-types@~2.0.9
|
|
||||||
- deps: mime-db@~1.7.0
|
|
||||||
* deps: negotiator@0.5.1
|
|
||||||
- Fix preference sorting to be stable for long acceptable lists
|
|
||||||
|
|
||||||
1.2.3 / 2015-01-31
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.8
|
|
||||||
- deps: mime-db@~1.6.0
|
|
||||||
|
|
||||||
1.2.2 / 2014-12-30
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.7
|
|
||||||
- deps: mime-db@~1.5.0
|
|
||||||
|
|
||||||
1.2.1 / 2014-12-30
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.5
|
|
||||||
- deps: mime-db@~1.3.1
|
|
||||||
|
|
||||||
1.2.0 / 2014-12-19
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.5.0
|
|
||||||
- Fix list return order when large accepted list
|
|
||||||
- Fix missing identity encoding when q=0 exists
|
|
||||||
- Remove dynamic building of Negotiator class
|
|
||||||
|
|
||||||
1.1.4 / 2014-12-10
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.4
|
|
||||||
- deps: mime-db@~1.3.0
|
|
||||||
|
|
||||||
1.1.3 / 2014-11-09
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.3
|
|
||||||
- deps: mime-db@~1.2.0
|
|
||||||
|
|
||||||
1.1.2 / 2014-10-14
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.4.9
|
|
||||||
- Fix error when media type has invalid parameter
|
|
||||||
|
|
||||||
1.1.1 / 2014-09-28
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: mime-types@~2.0.2
|
|
||||||
- deps: mime-db@~1.1.0
|
|
||||||
* deps: negotiator@0.4.8
|
|
||||||
- Fix all negotiations to be case-insensitive
|
|
||||||
- Stable sort preferences of same quality according to client order
|
|
||||||
|
|
||||||
1.1.0 / 2014-09-02
|
|
||||||
==================
|
|
||||||
|
|
||||||
* update `mime-types`
|
|
||||||
|
|
||||||
1.0.7 / 2014-07-04
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Fix wrong type returned from `type` when match after unknown extension
|
|
||||||
|
|
||||||
1.0.6 / 2014-06-24
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.4.7
|
|
||||||
|
|
||||||
1.0.5 / 2014-06-20
|
|
||||||
==================
|
|
||||||
|
|
||||||
* fix crash when unknown extension given
|
|
||||||
|
|
||||||
1.0.4 / 2014-06-19
|
|
||||||
==================
|
|
||||||
|
|
||||||
* use `mime-types`
|
|
||||||
|
|
||||||
1.0.3 / 2014-06-11
|
|
||||||
==================
|
|
||||||
|
|
||||||
* deps: negotiator@0.4.6
|
|
||||||
- Order by specificity when quality is the same
|
|
||||||
|
|
||||||
1.0.2 / 2014-05-29
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Fix interpretation when header not in request
|
|
||||||
* deps: pin negotiator@0.4.5
|
|
||||||
|
|
||||||
1.0.1 / 2014-01-18
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Identity encoding isn't always acceptable
|
|
||||||
* deps: negotiator@~0.4.0
|
|
||||||
|
|
||||||
1.0.0 / 2013-12-27
|
|
||||||
==================
|
|
||||||
|
|
||||||
* Genesis
|
|
||||||
23
server/node_modules/accepts/LICENSE
generated
vendored
23
server/node_modules/accepts/LICENSE
generated
vendored
@@ -1,23 +0,0 @@
|
|||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2014 Jonathan Ong <me@jongleberry.com>
|
|
||||||
Copyright (c) 2015 Douglas Christopher Wilson <doug@somethingdoug.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
140
server/node_modules/accepts/README.md
generated
vendored
140
server/node_modules/accepts/README.md
generated
vendored
@@ -1,140 +0,0 @@
|
|||||||
# accepts
|
|
||||||
|
|
||||||
[![NPM Version][npm-version-image]][npm-url]
|
|
||||||
[![NPM Downloads][npm-downloads-image]][npm-url]
|
|
||||||
[![Node.js Version][node-version-image]][node-version-url]
|
|
||||||
[![Build Status][github-actions-ci-image]][github-actions-ci-url]
|
|
||||||
[![Test Coverage][coveralls-image]][coveralls-url]
|
|
||||||
|
|
||||||
Higher level content negotiation based on [negotiator](https://www.npmjs.com/package/negotiator).
|
|
||||||
Extracted from [koa](https://www.npmjs.com/package/koa) for general use.
|
|
||||||
|
|
||||||
In addition to negotiator, it allows:
|
|
||||||
|
|
||||||
- Allows types as an array or arguments list, ie `(['text/html', 'application/json'])`
|
|
||||||
as well as `('text/html', 'application/json')`.
|
|
||||||
- Allows type shorthands such as `json`.
|
|
||||||
- Returns `false` when no types match
|
|
||||||
- Treats non-existent headers as `*`
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
This is a [Node.js](https://nodejs.org/en/) module available through the
|
|
||||||
[npm registry](https://www.npmjs.com/). Installation is done using the
|
|
||||||
[`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
$ npm install accepts
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
```js
|
|
||||||
var accepts = require('accepts')
|
|
||||||
```
|
|
||||||
|
|
||||||
### accepts(req)
|
|
||||||
|
|
||||||
Create a new `Accepts` object for the given `req`.
|
|
||||||
|
|
||||||
#### .charset(charsets)
|
|
||||||
|
|
||||||
Return the first accepted charset. If nothing in `charsets` is accepted,
|
|
||||||
then `false` is returned.
|
|
||||||
|
|
||||||
#### .charsets()
|
|
||||||
|
|
||||||
Return the charsets that the request accepts, in the order of the client's
|
|
||||||
preference (most preferred first).
|
|
||||||
|
|
||||||
#### .encoding(encodings)
|
|
||||||
|
|
||||||
Return the first accepted encoding. If nothing in `encodings` is accepted,
|
|
||||||
then `false` is returned.
|
|
||||||
|
|
||||||
#### .encodings()
|
|
||||||
|
|
||||||
Return the encodings that the request accepts, in the order of the client's
|
|
||||||
preference (most preferred first).
|
|
||||||
|
|
||||||
#### .language(languages)
|
|
||||||
|
|
||||||
Return the first accepted language. If nothing in `languages` is accepted,
|
|
||||||
then `false` is returned.
|
|
||||||
|
|
||||||
#### .languages()
|
|
||||||
|
|
||||||
Return the languages that the request accepts, in the order of the client's
|
|
||||||
preference (most preferred first).
|
|
||||||
|
|
||||||
#### .type(types)
|
|
||||||
|
|
||||||
Return the first accepted type (and it is returned as the same text as what
|
|
||||||
appears in the `types` array). If nothing in `types` is accepted, then `false`
|
|
||||||
is returned.
|
|
||||||
|
|
||||||
The `types` array can contain full MIME types or file extensions. Any value
|
|
||||||
that is not a full MIME types is passed to `require('mime-types').lookup`.
|
|
||||||
|
|
||||||
#### .types()
|
|
||||||
|
|
||||||
Return the types that the request accepts, in the order of the client's
|
|
||||||
preference (most preferred first).
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Simple type negotiation
|
|
||||||
|
|
||||||
This simple example shows how to use `accepts` to return a different typed
|
|
||||||
respond body based on what the client wants to accept. The server lists it's
|
|
||||||
preferences in order and will get back the best match between the client and
|
|
||||||
server.
|
|
||||||
|
|
||||||
```js
|
|
||||||
var accepts = require('accepts')
|
|
||||||
var http = require('http')
|
|
||||||
|
|
||||||
function app (req, res) {
|
|
||||||
var accept = accepts(req)
|
|
||||||
|
|
||||||
// the order of this list is significant; should be server preferred order
|
|
||||||
switch (accept.type(['json', 'html'])) {
|
|
||||||
case 'json':
|
|
||||||
res.setHeader('Content-Type', 'application/json')
|
|
||||||
res.write('{"hello":"world!"}')
|
|
||||||
break
|
|
||||||
case 'html':
|
|
||||||
res.setHeader('Content-Type', 'text/html')
|
|
||||||
res.write('<b>hello, world!</b>')
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
// the fallback is text/plain, so no need to specify it above
|
|
||||||
res.setHeader('Content-Type', 'text/plain')
|
|
||||||
res.write('hello, world!')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
res.end()
|
|
||||||
}
|
|
||||||
|
|
||||||
http.createServer(app).listen(3000)
|
|
||||||
```
|
|
||||||
|
|
||||||
You can test this out with the cURL program:
|
|
||||||
```sh
|
|
||||||
curl -I -H'Accept: text/html' http://localhost:3000/
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](LICENSE)
|
|
||||||
|
|
||||||
[coveralls-image]: https://badgen.net/coveralls/c/github/jshttp/accepts/master
|
|
||||||
[coveralls-url]: https://coveralls.io/r/jshttp/accepts?branch=master
|
|
||||||
[github-actions-ci-image]: https://badgen.net/github/checks/jshttp/accepts/master?label=ci
|
|
||||||
[github-actions-ci-url]: https://github.com/jshttp/accepts/actions/workflows/ci.yml
|
|
||||||
[node-version-image]: https://badgen.net/npm/node/accepts
|
|
||||||
[node-version-url]: https://nodejs.org/en/download
|
|
||||||
[npm-downloads-image]: https://badgen.net/npm/dm/accepts
|
|
||||||
[npm-url]: https://npmjs.org/package/accepts
|
|
||||||
[npm-version-image]: https://badgen.net/npm/v/accepts
|
|
||||||
238
server/node_modules/accepts/index.js
generated
vendored
238
server/node_modules/accepts/index.js
generated
vendored
@@ -1,238 +0,0 @@
|
|||||||
/*!
|
|
||||||
* accepts
|
|
||||||
* Copyright(c) 2014 Jonathan Ong
|
|
||||||
* Copyright(c) 2015 Douglas Christopher Wilson
|
|
||||||
* MIT Licensed
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module dependencies.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
var Negotiator = require('negotiator')
|
|
||||||
var mime = require('mime-types')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Module exports.
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
module.exports = Accepts
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new Accepts object for the given req.
|
|
||||||
*
|
|
||||||
* @param {object} req
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
function Accepts (req) {
|
|
||||||
if (!(this instanceof Accepts)) {
|
|
||||||
return new Accepts(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.headers = req.headers
|
|
||||||
this.negotiator = new Negotiator(req)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the given `type(s)` is acceptable, returning
|
|
||||||
* the best match when true, otherwise `undefined`, in which
|
|
||||||
* case you should respond with 406 "Not Acceptable".
|
|
||||||
*
|
|
||||||
* The `type` value may be a single mime type string
|
|
||||||
* such as "application/json", the extension name
|
|
||||||
* such as "json" or an array `["json", "html", "text/plain"]`. When a list
|
|
||||||
* or array is given the _best_ match, if any is returned.
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
*
|
|
||||||
* // Accept: text/html
|
|
||||||
* this.types('html');
|
|
||||||
* // => "html"
|
|
||||||
*
|
|
||||||
* // Accept: text/*, application/json
|
|
||||||
* this.types('html');
|
|
||||||
* // => "html"
|
|
||||||
* this.types('text/html');
|
|
||||||
* // => "text/html"
|
|
||||||
* this.types('json', 'text');
|
|
||||||
* // => "json"
|
|
||||||
* this.types('application/json');
|
|
||||||
* // => "application/json"
|
|
||||||
*
|
|
||||||
* // Accept: text/*, application/json
|
|
||||||
* this.types('image/png');
|
|
||||||
* this.types('png');
|
|
||||||
* // => undefined
|
|
||||||
*
|
|
||||||
* // Accept: text/*;q=.5, application/json
|
|
||||||
* this.types(['html', 'json']);
|
|
||||||
* this.types('html', 'json');
|
|
||||||
* // => "json"
|
|
||||||
*
|
|
||||||
* @param {String|Array} types...
|
|
||||||
* @return {String|Array|Boolean}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
Accepts.prototype.type =
|
|
||||||
Accepts.prototype.types = function (types_) {
|
|
||||||
var types = types_
|
|
||||||
|
|
||||||
// support flattened arguments
|
|
||||||
if (types && !Array.isArray(types)) {
|
|
||||||
types = new Array(arguments.length)
|
|
||||||
for (var i = 0; i < types.length; i++) {
|
|
||||||
types[i] = arguments[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no types, return all requested types
|
|
||||||
if (!types || types.length === 0) {
|
|
||||||
return this.negotiator.mediaTypes()
|
|
||||||
}
|
|
||||||
|
|
||||||
// no accept header, return first given type
|
|
||||||
if (!this.headers.accept) {
|
|
||||||
return types[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
var mimes = types.map(extToMime)
|
|
||||||
var accepts = this.negotiator.mediaTypes(mimes.filter(validMime))
|
|
||||||
var first = accepts[0]
|
|
||||||
|
|
||||||
return first
|
|
||||||
? types[mimes.indexOf(first)]
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return accepted encodings or best fit based on `encodings`.
|
|
||||||
*
|
|
||||||
* Given `Accept-Encoding: gzip, deflate`
|
|
||||||
* an array sorted by quality is returned:
|
|
||||||
*
|
|
||||||
* ['gzip', 'deflate']
|
|
||||||
*
|
|
||||||
* @param {String|Array} encodings...
|
|
||||||
* @return {String|Array}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
Accepts.prototype.encoding =
|
|
||||||
Accepts.prototype.encodings = function (encodings_) {
|
|
||||||
var encodings = encodings_
|
|
||||||
|
|
||||||
// support flattened arguments
|
|
||||||
if (encodings && !Array.isArray(encodings)) {
|
|
||||||
encodings = new Array(arguments.length)
|
|
||||||
for (var i = 0; i < encodings.length; i++) {
|
|
||||||
encodings[i] = arguments[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no encodings, return all requested encodings
|
|
||||||
if (!encodings || encodings.length === 0) {
|
|
||||||
return this.negotiator.encodings()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.negotiator.encodings(encodings)[0] || false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return accepted charsets or best fit based on `charsets`.
|
|
||||||
*
|
|
||||||
* Given `Accept-Charset: utf-8, iso-8859-1;q=0.2, utf-7;q=0.5`
|
|
||||||
* an array sorted by quality is returned:
|
|
||||||
*
|
|
||||||
* ['utf-8', 'utf-7', 'iso-8859-1']
|
|
||||||
*
|
|
||||||
* @param {String|Array} charsets...
|
|
||||||
* @return {String|Array}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
Accepts.prototype.charset =
|
|
||||||
Accepts.prototype.charsets = function (charsets_) {
|
|
||||||
var charsets = charsets_
|
|
||||||
|
|
||||||
// support flattened arguments
|
|
||||||
if (charsets && !Array.isArray(charsets)) {
|
|
||||||
charsets = new Array(arguments.length)
|
|
||||||
for (var i = 0; i < charsets.length; i++) {
|
|
||||||
charsets[i] = arguments[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no charsets, return all requested charsets
|
|
||||||
if (!charsets || charsets.length === 0) {
|
|
||||||
return this.negotiator.charsets()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.negotiator.charsets(charsets)[0] || false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return accepted languages or best fit based on `langs`.
|
|
||||||
*
|
|
||||||
* Given `Accept-Language: en;q=0.8, es, pt`
|
|
||||||
* an array sorted by quality is returned:
|
|
||||||
*
|
|
||||||
* ['es', 'pt', 'en']
|
|
||||||
*
|
|
||||||
* @param {String|Array} langs...
|
|
||||||
* @return {Array|String}
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
|
|
||||||
Accepts.prototype.lang =
|
|
||||||
Accepts.prototype.langs =
|
|
||||||
Accepts.prototype.language =
|
|
||||||
Accepts.prototype.languages = function (languages_) {
|
|
||||||
var languages = languages_
|
|
||||||
|
|
||||||
// support flattened arguments
|
|
||||||
if (languages && !Array.isArray(languages)) {
|
|
||||||
languages = new Array(arguments.length)
|
|
||||||
for (var i = 0; i < languages.length; i++) {
|
|
||||||
languages[i] = arguments[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// no languages, return all requested languages
|
|
||||||
if (!languages || languages.length === 0) {
|
|
||||||
return this.negotiator.languages()
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.negotiator.languages(languages)[0] || false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert extnames to mime.
|
|
||||||
*
|
|
||||||
* @param {String} type
|
|
||||||
* @return {String}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function extToMime (type) {
|
|
||||||
return type.indexOf('/') === -1
|
|
||||||
? mime.lookup(type)
|
|
||||||
: type
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if mime is valid.
|
|
||||||
*
|
|
||||||
* @param {String} type
|
|
||||||
* @return {String}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
|
|
||||||
function validMime (type) {
|
|
||||||
return typeof type === 'string'
|
|
||||||
}
|
|
||||||
47
server/node_modules/accepts/package.json
generated
vendored
47
server/node_modules/accepts/package.json
generated
vendored
@@ -1,47 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "accepts",
|
|
||||||
"description": "Higher-level content negotiation",
|
|
||||||
"version": "1.3.8",
|
|
||||||
"contributors": [
|
|
||||||
"Douglas Christopher Wilson <doug@somethingdoug.com>",
|
|
||||||
"Jonathan Ong <me@jongleberry.com> (http://jongleberry.com)"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"repository": "jshttp/accepts",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-types": "~2.1.34",
|
|
||||||
"negotiator": "0.6.3"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"deep-equal": "1.0.1",
|
|
||||||
"eslint": "7.32.0",
|
|
||||||
"eslint-config-standard": "14.1.1",
|
|
||||||
"eslint-plugin-import": "2.25.4",
|
|
||||||
"eslint-plugin-markdown": "2.2.1",
|
|
||||||
"eslint-plugin-node": "11.1.0",
|
|
||||||
"eslint-plugin-promise": "4.3.1",
|
|
||||||
"eslint-plugin-standard": "4.1.0",
|
|
||||||
"mocha": "9.2.0",
|
|
||||||
"nyc": "15.1.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"LICENSE",
|
|
||||||
"HISTORY.md",
|
|
||||||
"index.js"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"lint": "eslint .",
|
|
||||||
"test": "mocha --reporter spec --check-leaks --bail test/",
|
|
||||||
"test-ci": "nyc --reporter=lcov --reporter=text npm test",
|
|
||||||
"test-cov": "nyc --reporter=html --reporter=text npm test"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"content",
|
|
||||||
"negotiation",
|
|
||||||
"accept",
|
|
||||||
"accepts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
145
server/node_modules/agent-base/README.md
generated
vendored
145
server/node_modules/agent-base/README.md
generated
vendored
@@ -1,145 +0,0 @@
|
|||||||
agent-base
|
|
||||||
==========
|
|
||||||
### Turn a function into an [`http.Agent`][http.Agent] instance
|
|
||||||
[](https://github.com/TooTallNate/node-agent-base/actions?workflow=Node+CI)
|
|
||||||
|
|
||||||
This module provides an `http.Agent` generator. That is, you pass it an async
|
|
||||||
callback function, and it returns a new `http.Agent` instance that will invoke the
|
|
||||||
given callback function when sending outbound HTTP requests.
|
|
||||||
|
|
||||||
#### Some subclasses:
|
|
||||||
|
|
||||||
Here's some more interesting uses of `agent-base`.
|
|
||||||
Send a pull request to list yours!
|
|
||||||
|
|
||||||
* [`http-proxy-agent`][http-proxy-agent]: An HTTP(s) proxy `http.Agent` implementation for HTTP endpoints
|
|
||||||
* [`https-proxy-agent`][https-proxy-agent]: An HTTP(s) proxy `http.Agent` implementation for HTTPS endpoints
|
|
||||||
* [`pac-proxy-agent`][pac-proxy-agent]: A PAC file proxy `http.Agent` implementation for HTTP and HTTPS
|
|
||||||
* [`socks-proxy-agent`][socks-proxy-agent]: A SOCKS proxy `http.Agent` implementation for HTTP and HTTPS
|
|
||||||
|
|
||||||
|
|
||||||
Installation
|
|
||||||
------------
|
|
||||||
|
|
||||||
Install with `npm`:
|
|
||||||
|
|
||||||
``` bash
|
|
||||||
$ npm install agent-base
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
Example
|
|
||||||
-------
|
|
||||||
|
|
||||||
Here's a minimal example that creates a new `net.Socket` connection to the server
|
|
||||||
for every HTTP request (i.e. the equivalent of `agent: false` option):
|
|
||||||
|
|
||||||
```js
|
|
||||||
var net = require('net');
|
|
||||||
var tls = require('tls');
|
|
||||||
var url = require('url');
|
|
||||||
var http = require('http');
|
|
||||||
var agent = require('agent-base');
|
|
||||||
|
|
||||||
var endpoint = 'http://nodejs.org/api/';
|
|
||||||
var parsed = url.parse(endpoint);
|
|
||||||
|
|
||||||
// This is the important part!
|
|
||||||
parsed.agent = agent(function (req, opts) {
|
|
||||||
var socket;
|
|
||||||
// `secureEndpoint` is true when using the https module
|
|
||||||
if (opts.secureEndpoint) {
|
|
||||||
socket = tls.connect(opts);
|
|
||||||
} else {
|
|
||||||
socket = net.connect(opts);
|
|
||||||
}
|
|
||||||
return socket;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Everything else works just like normal...
|
|
||||||
http.get(parsed, function (res) {
|
|
||||||
console.log('"response" event!', res.headers);
|
|
||||||
res.pipe(process.stdout);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Returning a Promise or using an `async` function is also supported:
|
|
||||||
|
|
||||||
```js
|
|
||||||
agent(async function (req, opts) {
|
|
||||||
await sleep(1000);
|
|
||||||
// etc…
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Return another `http.Agent` instance to "pass through" the responsibility
|
|
||||||
for that HTTP request to that agent:
|
|
||||||
|
|
||||||
```js
|
|
||||||
agent(function (req, opts) {
|
|
||||||
return opts.secureEndpoint ? https.globalAgent : http.globalAgent;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
API
|
|
||||||
---
|
|
||||||
|
|
||||||
## Agent(Function callback[, Object options]) → [http.Agent][]
|
|
||||||
|
|
||||||
Creates a base `http.Agent` that will execute the callback function `callback`
|
|
||||||
for every HTTP request that it is used as the `agent` for. The callback function
|
|
||||||
is responsible for creating a `stream.Duplex` instance of some kind that will be
|
|
||||||
used as the underlying socket in the HTTP request.
|
|
||||||
|
|
||||||
The `options` object accepts the following properties:
|
|
||||||
|
|
||||||
* `timeout` - Number - Timeout for the `callback()` function in milliseconds. Defaults to Infinity (optional).
|
|
||||||
|
|
||||||
The callback function should have the following signature:
|
|
||||||
|
|
||||||
### callback(http.ClientRequest req, Object options, Function cb) → undefined
|
|
||||||
|
|
||||||
The ClientRequest `req` can be accessed to read request headers and
|
|
||||||
and the path, etc. The `options` object contains the options passed
|
|
||||||
to the `http.request()`/`https.request()` function call, and is formatted
|
|
||||||
to be directly passed to `net.connect()`/`tls.connect()`, or however
|
|
||||||
else you want a Socket to be created. Pass the created socket to
|
|
||||||
the callback function `cb` once created, and the HTTP request will
|
|
||||||
continue to proceed.
|
|
||||||
|
|
||||||
If the `https` module is used to invoke the HTTP request, then the
|
|
||||||
`secureEndpoint` property on `options` _will be set to `true`_.
|
|
||||||
|
|
||||||
|
|
||||||
License
|
|
||||||
-------
|
|
||||||
|
|
||||||
(The MIT License)
|
|
||||||
|
|
||||||
Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
a copy of this software and associated documentation files (the
|
|
||||||
'Software'), to deal in the Software without restriction, including
|
|
||||||
without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be
|
|
||||||
included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
||||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
|
||||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
||||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|
||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
[http-proxy-agent]: https://github.com/TooTallNate/node-http-proxy-agent
|
|
||||||
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
|
|
||||||
[pac-proxy-agent]: https://github.com/TooTallNate/node-pac-proxy-agent
|
|
||||||
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
|
|
||||||
[http.Agent]: https://nodejs.org/api/http.html#http_class_http_agent
|
|
||||||
78
server/node_modules/agent-base/dist/src/index.d.ts
generated
vendored
78
server/node_modules/agent-base/dist/src/index.d.ts
generated
vendored
@@ -1,78 +0,0 @@
|
|||||||
/// <reference types="node" />
|
|
||||||
import net from 'net';
|
|
||||||
import http from 'http';
|
|
||||||
import https from 'https';
|
|
||||||
import { Duplex } from 'stream';
|
|
||||||
import { EventEmitter } from 'events';
|
|
||||||
declare function createAgent(opts?: createAgent.AgentOptions): createAgent.Agent;
|
|
||||||
declare function createAgent(callback: createAgent.AgentCallback, opts?: createAgent.AgentOptions): createAgent.Agent;
|
|
||||||
declare namespace createAgent {
|
|
||||||
interface ClientRequest extends http.ClientRequest {
|
|
||||||
_last?: boolean;
|
|
||||||
_hadError?: boolean;
|
|
||||||
method: string;
|
|
||||||
}
|
|
||||||
interface AgentRequestOptions {
|
|
||||||
host?: string;
|
|
||||||
path?: string;
|
|
||||||
port: number;
|
|
||||||
}
|
|
||||||
interface HttpRequestOptions extends AgentRequestOptions, Omit<http.RequestOptions, keyof AgentRequestOptions> {
|
|
||||||
secureEndpoint: false;
|
|
||||||
}
|
|
||||||
interface HttpsRequestOptions extends AgentRequestOptions, Omit<https.RequestOptions, keyof AgentRequestOptions> {
|
|
||||||
secureEndpoint: true;
|
|
||||||
}
|
|
||||||
type RequestOptions = HttpRequestOptions | HttpsRequestOptions;
|
|
||||||
type AgentLike = Pick<createAgent.Agent, 'addRequest'> | http.Agent;
|
|
||||||
type AgentCallbackReturn = Duplex | AgentLike;
|
|
||||||
type AgentCallbackCallback = (err?: Error | null, socket?: createAgent.AgentCallbackReturn) => void;
|
|
||||||
type AgentCallbackPromise = (req: createAgent.ClientRequest, opts: createAgent.RequestOptions) => createAgent.AgentCallbackReturn | Promise<createAgent.AgentCallbackReturn>;
|
|
||||||
type AgentCallback = typeof Agent.prototype.callback;
|
|
||||||
type AgentOptions = {
|
|
||||||
timeout?: number;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Base `http.Agent` implementation.
|
|
||||||
* No pooling/keep-alive is implemented by default.
|
|
||||||
*
|
|
||||||
* @param {Function} callback
|
|
||||||
* @api public
|
|
||||||
*/
|
|
||||||
class Agent extends EventEmitter {
|
|
||||||
timeout: number | null;
|
|
||||||
maxFreeSockets: number;
|
|
||||||
maxTotalSockets: number;
|
|
||||||
maxSockets: number;
|
|
||||||
sockets: {
|
|
||||||
[key: string]: net.Socket[];
|
|
||||||
};
|
|
||||||
freeSockets: {
|
|
||||||
[key: string]: net.Socket[];
|
|
||||||
};
|
|
||||||
requests: {
|
|
||||||
[key: string]: http.IncomingMessage[];
|
|
||||||
};
|
|
||||||
options: https.AgentOptions;
|
|
||||||
private promisifiedCallback?;
|
|
||||||
private explicitDefaultPort?;
|
|
||||||
private explicitProtocol?;
|
|
||||||
constructor(callback?: createAgent.AgentCallback | createAgent.AgentOptions, _opts?: createAgent.AgentOptions);
|
|
||||||
get defaultPort(): number;
|
|
||||||
set defaultPort(v: number);
|
|
||||||
get protocol(): string;
|
|
||||||
set protocol(v: string);
|
|
||||||
callback(req: createAgent.ClientRequest, opts: createAgent.RequestOptions, fn: createAgent.AgentCallbackCallback): void;
|
|
||||||
callback(req: createAgent.ClientRequest, opts: createAgent.RequestOptions): createAgent.AgentCallbackReturn | Promise<createAgent.AgentCallbackReturn>;
|
|
||||||
/**
|
|
||||||
* Called by node-core's "_http_client.js" module when creating
|
|
||||||
* a new HTTP request with this Agent instance.
|
|
||||||
*
|
|
||||||
* @api public
|
|
||||||
*/
|
|
||||||
addRequest(req: ClientRequest, _opts: RequestOptions): void;
|
|
||||||
freeSocket(socket: net.Socket, opts: AgentOptions): void;
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export = createAgent;
|
|
||||||
203
server/node_modules/agent-base/dist/src/index.js
generated
vendored
203
server/node_modules/agent-base/dist/src/index.js
generated
vendored
@@ -1,203 +0,0 @@
|
|||||||
"use strict";
|
|
||||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
||||||
};
|
|
||||||
const events_1 = require("events");
|
|
||||||
const debug_1 = __importDefault(require("debug"));
|
|
||||||
const promisify_1 = __importDefault(require("./promisify"));
|
|
||||||
const debug = debug_1.default('agent-base');
|
|
||||||
function isAgent(v) {
|
|
||||||
return Boolean(v) && typeof v.addRequest === 'function';
|
|
||||||
}
|
|
||||||
function isSecureEndpoint() {
|
|
||||||
const { stack } = new Error();
|
|
||||||
if (typeof stack !== 'string')
|
|
||||||
return false;
|
|
||||||
return stack.split('\n').some(l => l.indexOf('(https.js:') !== -1 || l.indexOf('node:https:') !== -1);
|
|
||||||
}
|
|
||||||
function createAgent(callback, opts) {
|
|
||||||
return new createAgent.Agent(callback, opts);
|
|
||||||
}
|
|
||||||
(function (createAgent) {
|
|
||||||
/**
|
|
||||||
* Base `http.Agent` implementation.
|
|
||||||
* No pooling/keep-alive is implemented by default.
|
|
||||||
*
|
|
||||||
* @param {Function} callback
|
|
||||||
* @api public
|
|
||||||
*/
|
|
||||||
class Agent extends events_1.EventEmitter {
|
|
||||||
constructor(callback, _opts) {
|
|
||||||
super();
|
|
||||||
let opts = _opts;
|
|
||||||
if (typeof callback === 'function') {
|
|
||||||
this.callback = callback;
|
|
||||||
}
|
|
||||||
else if (callback) {
|
|
||||||
opts = callback;
|
|
||||||
}
|
|
||||||
// Timeout for the socket to be returned from the callback
|
|
||||||
this.timeout = null;
|
|
||||||
if (opts && typeof opts.timeout === 'number') {
|
|
||||||
this.timeout = opts.timeout;
|
|
||||||
}
|
|
||||||
// These aren't actually used by `agent-base`, but are required
|
|
||||||
// for the TypeScript definition files in `@types/node` :/
|
|
||||||
this.maxFreeSockets = 1;
|
|
||||||
this.maxSockets = 1;
|
|
||||||
this.maxTotalSockets = Infinity;
|
|
||||||
this.sockets = {};
|
|
||||||
this.freeSockets = {};
|
|
||||||
this.requests = {};
|
|
||||||
this.options = {};
|
|
||||||
}
|
|
||||||
get defaultPort() {
|
|
||||||
if (typeof this.explicitDefaultPort === 'number') {
|
|
||||||
return this.explicitDefaultPort;
|
|
||||||
}
|
|
||||||
return isSecureEndpoint() ? 443 : 80;
|
|
||||||
}
|
|
||||||
set defaultPort(v) {
|
|
||||||
this.explicitDefaultPort = v;
|
|
||||||
}
|
|
||||||
get protocol() {
|
|
||||||
if (typeof this.explicitProtocol === 'string') {
|
|
||||||
return this.explicitProtocol;
|
|
||||||
}
|
|
||||||
return isSecureEndpoint() ? 'https:' : 'http:';
|
|
||||||
}
|
|
||||||
set protocol(v) {
|
|
||||||
this.explicitProtocol = v;
|
|
||||||
}
|
|
||||||
callback(req, opts, fn) {
|
|
||||||
throw new Error('"agent-base" has no default implementation, you must subclass and override `callback()`');
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Called by node-core's "_http_client.js" module when creating
|
|
||||||
* a new HTTP request with this Agent instance.
|
|
||||||
*
|
|
||||||
* @api public
|
|
||||||
*/
|
|
||||||
addRequest(req, _opts) {
|
|
||||||
const opts = Object.assign({}, _opts);
|
|
||||||
if (typeof opts.secureEndpoint !== 'boolean') {
|
|
||||||
opts.secureEndpoint = isSecureEndpoint();
|
|
||||||
}
|
|
||||||
if (opts.host == null) {
|
|
||||||
opts.host = 'localhost';
|
|
||||||
}
|
|
||||||
if (opts.port == null) {
|
|
||||||
opts.port = opts.secureEndpoint ? 443 : 80;
|
|
||||||
}
|
|
||||||
if (opts.protocol == null) {
|
|
||||||
opts.protocol = opts.secureEndpoint ? 'https:' : 'http:';
|
|
||||||
}
|
|
||||||
if (opts.host && opts.path) {
|
|
||||||
// If both a `host` and `path` are specified then it's most
|
|
||||||
// likely the result of a `url.parse()` call... we need to
|
|
||||||
// remove the `path` portion so that `net.connect()` doesn't
|
|
||||||
// attempt to open that as a unix socket file.
|
|
||||||
delete opts.path;
|
|
||||||
}
|
|
||||||
delete opts.agent;
|
|
||||||
delete opts.hostname;
|
|
||||||
delete opts._defaultAgent;
|
|
||||||
delete opts.defaultPort;
|
|
||||||
delete opts.createConnection;
|
|
||||||
// Hint to use "Connection: close"
|
|
||||||
// XXX: non-documented `http` module API :(
|
|
||||||
req._last = true;
|
|
||||||
req.shouldKeepAlive = false;
|
|
||||||
let timedOut = false;
|
|
||||||
let timeoutId = null;
|
|
||||||
const timeoutMs = opts.timeout || this.timeout;
|
|
||||||
const onerror = (err) => {
|
|
||||||
if (req._hadError)
|
|
||||||
return;
|
|
||||||
req.emit('error', err);
|
|
||||||
// For Safety. Some additional errors might fire later on
|
|
||||||
// and we need to make sure we don't double-fire the error event.
|
|
||||||
req._hadError = true;
|
|
||||||
};
|
|
||||||
const ontimeout = () => {
|
|
||||||
timeoutId = null;
|
|
||||||
timedOut = true;
|
|
||||||
const err = new Error(`A "socket" was not created for HTTP request before ${timeoutMs}ms`);
|
|
||||||
err.code = 'ETIMEOUT';
|
|
||||||
onerror(err);
|
|
||||||
};
|
|
||||||
const callbackError = (err) => {
|
|
||||||
if (timedOut)
|
|
||||||
return;
|
|
||||||
if (timeoutId !== null) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeoutId = null;
|
|
||||||
}
|
|
||||||
onerror(err);
|
|
||||||
};
|
|
||||||
const onsocket = (socket) => {
|
|
||||||
if (timedOut)
|
|
||||||
return;
|
|
||||||
if (timeoutId != null) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeoutId = null;
|
|
||||||
}
|
|
||||||
if (isAgent(socket)) {
|
|
||||||
// `socket` is actually an `http.Agent` instance, so
|
|
||||||
// relinquish responsibility for this `req` to the Agent
|
|
||||||
// from here on
|
|
||||||
debug('Callback returned another Agent instance %o', socket.constructor.name);
|
|
||||||
socket.addRequest(req, opts);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (socket) {
|
|
||||||
socket.once('free', () => {
|
|
||||||
this.freeSocket(socket, opts);
|
|
||||||
});
|
|
||||||
req.onSocket(socket);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const err = new Error(`no Duplex stream was returned to agent-base for \`${req.method} ${req.path}\``);
|
|
||||||
onerror(err);
|
|
||||||
};
|
|
||||||
if (typeof this.callback !== 'function') {
|
|
||||||
onerror(new Error('`callback` is not defined'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!this.promisifiedCallback) {
|
|
||||||
if (this.callback.length >= 3) {
|
|
||||||
debug('Converting legacy callback function to promise');
|
|
||||||
this.promisifiedCallback = promisify_1.default(this.callback);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
this.promisifiedCallback = this.callback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
|
||||||
timeoutId = setTimeout(ontimeout, timeoutMs);
|
|
||||||
}
|
|
||||||
if ('port' in opts && typeof opts.port !== 'number') {
|
|
||||||
opts.port = Number(opts.port);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
debug('Resolving socket for %o request: %o', opts.protocol, `${req.method} ${req.path}`);
|
|
||||||
Promise.resolve(this.promisifiedCallback(req, opts)).then(onsocket, callbackError);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
Promise.reject(err).catch(callbackError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
freeSocket(socket, opts) {
|
|
||||||
debug('Freeing socket %o %o', socket.constructor.name, opts);
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
destroy() {
|
|
||||||
debug('Destroying agent %o', this.constructor.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createAgent.Agent = Agent;
|
|
||||||
// So that `instanceof` works correctly
|
|
||||||
createAgent.prototype = createAgent.Agent.prototype;
|
|
||||||
})(createAgent || (createAgent = {}));
|
|
||||||
module.exports = createAgent;
|
|
||||||
//# sourceMappingURL=index.js.map
|
|
||||||
1
server/node_modules/agent-base/dist/src/index.js.map
generated
vendored
1
server/node_modules/agent-base/dist/src/index.js.map
generated
vendored
File diff suppressed because one or more lines are too long
4
server/node_modules/agent-base/dist/src/promisify.d.ts
generated
vendored
4
server/node_modules/agent-base/dist/src/promisify.d.ts
generated
vendored
@@ -1,4 +0,0 @@
|
|||||||
import { ClientRequest, RequestOptions, AgentCallbackCallback, AgentCallbackPromise } from './index';
|
|
||||||
declare type LegacyCallback = (req: ClientRequest, opts: RequestOptions, fn: AgentCallbackCallback) => void;
|
|
||||||
export default function promisify(fn: LegacyCallback): AgentCallbackPromise;
|
|
||||||
export {};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user