carnet panel
This commit is contained in:
parent
970908d605
commit
a5e51cdd6b
@ -8,6 +8,7 @@ import { NotesView } from "@/components/carnet/notes-view";
|
|||||||
import { Editor } from "@/components/carnet/editor";
|
import { Editor } from "@/components/carnet/editor";
|
||||||
import { PanelResizer } from "@/components/carnet/panel-resizer";
|
import { PanelResizer } from "@/components/carnet/panel-resizer";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { ContactsView } from '@/components/carnet/contacts-view';
|
||||||
|
|
||||||
// Layout modes
|
// Layout modes
|
||||||
export enum PaneLayout {
|
export enum PaneLayout {
|
||||||
@ -27,6 +28,17 @@ interface Note {
|
|||||||
etag: string;
|
etag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
organization?: string;
|
||||||
|
address?: string;
|
||||||
|
notes?: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CarnetPage() {
|
export default function CarnetPage() {
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@ -39,6 +51,9 @@ export default function CarnetPage() {
|
|||||||
const [selectedFolder, setSelectedFolder] = useState<string>('Notes');
|
const [selectedFolder, setSelectedFolder] = useState<string>('Notes');
|
||||||
const [notes, setNotes] = useState<Note[]>([]);
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
const [isLoadingNotes, setIsLoadingNotes] = useState(true);
|
const [isLoadingNotes, setIsLoadingNotes] = useState(true);
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||||
|
const [isLoadingContacts, setIsLoadingContacts] = useState(true);
|
||||||
|
|
||||||
// Panel widths state
|
// Panel widths state
|
||||||
const [navWidth, setNavWidth] = useState(220);
|
const [navWidth, setNavWidth] = useState(220);
|
||||||
@ -136,6 +151,72 @@ export default function CarnetPage() {
|
|||||||
fetchNotes();
|
fetchNotes();
|
||||||
}, [selectedFolder]);
|
}, [selectedFolder]);
|
||||||
|
|
||||||
|
const fetchContacts = async (folder: string) => {
|
||||||
|
try {
|
||||||
|
setIsLoadingContacts(true);
|
||||||
|
const response = await fetch(`/api/nextcloud/files?folder=${folder}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const files = await response.json();
|
||||||
|
const vcfFiles = files.filter((file: any) => file.basename.endsWith('.vcf'));
|
||||||
|
|
||||||
|
// Parse VCF files and extract contact information
|
||||||
|
const parsedContacts = await Promise.all(
|
||||||
|
vcfFiles.map(async (file: any) => {
|
||||||
|
const contentResponse = await fetch(`/api/nextcloud/files/content?id=${file.id}`);
|
||||||
|
if (contentResponse.ok) {
|
||||||
|
const content = await contentResponse.text();
|
||||||
|
return parseVCard(content);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setContacts(parsedContacts.filter(Boolean));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching contacts:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingContacts(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseVCard = (content: string): Contact => {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const contact: Partial<Contact> = {};
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
if (line.startsWith('FN:')) {
|
||||||
|
contact.fullName = line.substring(3).trim();
|
||||||
|
} else if (line.startsWith('EMAIL;')) {
|
||||||
|
contact.email = line.split(':')[1].trim();
|
||||||
|
} else if (line.startsWith('TEL;')) {
|
||||||
|
contact.phone = line.split(':')[1].trim();
|
||||||
|
} else if (line.startsWith('ORG:')) {
|
||||||
|
contact.organization = line.substring(4).trim();
|
||||||
|
} else if (line.startsWith('ADR;')) {
|
||||||
|
contact.address = line.split(':')[1].trim();
|
||||||
|
} else if (line.startsWith('NOTE:')) {
|
||||||
|
contact.notes = line.substring(5).trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Math.random().toString(36).substr(2, 9),
|
||||||
|
fullName: contact.fullName || 'Unknown',
|
||||||
|
email: contact.email || '',
|
||||||
|
phone: contact.phone,
|
||||||
|
organization: contact.organization,
|
||||||
|
address: contact.address,
|
||||||
|
notes: contact.notes
|
||||||
|
} as Contact;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedFolder === 'Contacts') {
|
||||||
|
fetchContacts(selectedFolder);
|
||||||
|
}
|
||||||
|
}, [selectedFolder]);
|
||||||
|
|
||||||
// Handle panel resizing
|
// Handle panel resizing
|
||||||
const handleNavResize = (e: MouseEvent) => {
|
const handleNavResize = (e: MouseEvent) => {
|
||||||
if (!isDraggingNav) return;
|
if (!isDraggingNav) return;
|
||||||
@ -286,14 +367,23 @@ export default function CarnetPage() {
|
|||||||
{showNotes && (
|
{showNotes && (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<NotesView
|
{selectedFolder === 'Contacts' ? (
|
||||||
notes={notes}
|
<ContactsView
|
||||||
loading={isLoadingNotes}
|
contacts={contacts}
|
||||||
onNoteSelect={handleNoteSelect}
|
onContactSelect={setSelectedContact}
|
||||||
currentFolder={selectedFolder}
|
selectedContact={selectedContact}
|
||||||
onNewNote={handleNewNote}
|
loading={isLoadingContacts}
|
||||||
onDeleteNote={handleDeleteNote}
|
/>
|
||||||
/>
|
) : (
|
||||||
|
<NotesView
|
||||||
|
notes={notes}
|
||||||
|
loading={isLoadingNotes}
|
||||||
|
onNoteSelect={handleNoteSelect}
|
||||||
|
currentFolder={selectedFolder}
|
||||||
|
onNewNote={handleNewNote}
|
||||||
|
onDeleteNote={handleDeleteNote}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes Resizer */}
|
{/* Notes Resizer */}
|
||||||
|
|||||||
139
components/carnet/contacts-view.tsx
Normal file
139
components/carnet/contacts-view.tsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Search, User, Mail, Phone, Building, MapPin, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Contact {
|
||||||
|
id: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
organization?: string;
|
||||||
|
address?: string;
|
||||||
|
notes?: string;
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactsViewProps {
|
||||||
|
contacts: Contact[];
|
||||||
|
onContactSelect: (contact: Contact) => void;
|
||||||
|
selectedContact: Contact | null;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContactsView: React.FC<ContactsViewProps> = ({
|
||||||
|
contacts,
|
||||||
|
onContactSelect,
|
||||||
|
selectedContact,
|
||||||
|
loading = false
|
||||||
|
}) => {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const filteredContacts = contacts.filter(contact =>
|
||||||
|
contact.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
contact.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
contact.organization?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-carnet-bg border-r border-carnet-border">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="p-4 border-b border-carnet-border">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Rechercher un contact..."
|
||||||
|
className="w-full pl-9 pr-4 py-2 bg-white border border-carnet-border rounded-md text-sm text-carnet-text-primary placeholder-carnet-text-muted focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-carnet-text-muted" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contacts List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-4 text-center text-carnet-text-muted">Chargement...</div>
|
||||||
|
) : filteredContacts.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-carnet-text-muted">Aucun contact trouvé</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-carnet-border">
|
||||||
|
{filteredContacts.map((contact) => (
|
||||||
|
<li
|
||||||
|
key={contact.id}
|
||||||
|
onClick={() => onContactSelect(contact)}
|
||||||
|
className={`p-4 cursor-pointer hover:bg-carnet-hover ${
|
||||||
|
selectedContact?.id === contact.id ? 'bg-carnet-hover' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<User className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-carnet-text-primary">{contact.fullName}</div>
|
||||||
|
<div className="text-sm text-carnet-text-muted">{contact.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-4 w-4 text-carnet-text-muted" />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Details */}
|
||||||
|
{selectedContact && (
|
||||||
|
<div className="p-4 border-t border-carnet-border">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<User className="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-carnet-text-primary">
|
||||||
|
{selectedContact.fullName}
|
||||||
|
</h3>
|
||||||
|
{selectedContact.organization && (
|
||||||
|
<p className="text-sm text-carnet-text-muted">
|
||||||
|
{selectedContact.organization}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{selectedContact.email && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Mail className="h-4 w-4 text-carnet-text-muted" />
|
||||||
|
<span className="text-sm text-carnet-text-primary">{selectedContact.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedContact.phone && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Phone className="h-4 w-4 text-carnet-text-muted" />
|
||||||
|
<span className="text-sm text-carnet-text-primary">{selectedContact.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedContact.address && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<MapPin className="h-4 w-4 text-carnet-text-muted" />
|
||||||
|
<span className="text-sm text-carnet-text-primary">{selectedContact.address}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedContact.notes && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="text-sm font-medium text-carnet-text-primary mb-2">Notes</h4>
|
||||||
|
<p className="text-sm text-carnet-text-muted">{selectedContact.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,30 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Search, BookOpen, Tag, Trash2, Star, Archive, X, Folder, FileText, Calendar, Heart, Users, LucideIcon } from 'lucide-react';
|
import { Search, FileText, Calendar, Heart, Users, ChevronRight, Folder } from 'lucide-react';
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
nextcloudFolders: string[];
|
nextcloudFolders: string[];
|
||||||
onFolderSelect: (folder: string) => void;
|
onFolderSelect: (folder: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FolderType = 'Notes' | 'Diary' | 'Health' | 'Contacts';
|
interface ContactGroup {
|
||||||
|
name: string;
|
||||||
interface FolderConfig {
|
contacts: string[];
|
||||||
icon: LucideIcon;
|
|
||||||
order: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define folder order and icons
|
|
||||||
const FOLDER_CONFIG: Record<FolderType, FolderConfig> = {
|
|
||||||
'Notes': { icon: FileText, order: 1 },
|
|
||||||
'Diary': { icon: Calendar, order: 2 },
|
|
||||||
'Health': { icon: Heart, order: 3 },
|
|
||||||
'Contacts': { icon: Users, order: 4 }
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Navigation({ nextcloudFolders, onFolderSelect }: NavigationProps) {
|
export default function Navigation({ nextcloudFolders, onFolderSelect }: NavigationProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
const [contactGroups, setContactGroups] = useState<ContactGroup[]>([
|
||||||
|
{ name: 'Family', contacts: [] },
|
||||||
|
{ name: 'Friends', contacts: [] },
|
||||||
|
{ name: 'Work', contacts: [] },
|
||||||
|
{ name: 'Other', contacts: [] }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const toggleGroup = (groupName: string) => {
|
||||||
|
const newExpanded = new Set(expandedGroups);
|
||||||
|
if (newExpanded.has(groupName)) {
|
||||||
|
newExpanded.delete(groupName);
|
||||||
|
} else {
|
||||||
|
newExpanded.add(groupName);
|
||||||
|
}
|
||||||
|
setExpandedGroups(newExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
const getFolderIcon = (folder: string) => {
|
const getFolderIcon = (folder: string) => {
|
||||||
switch (folder) {
|
switch (folder) {
|
||||||
@ -41,54 +48,82 @@ export default function Navigation({ nextcloudFolders, onFolderSelect }: Navigat
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sort folders according to the specified order
|
|
||||||
const sortedFolders = [...nextcloudFolders].sort((a, b) => {
|
|
||||||
const orderA = (FOLDER_CONFIG[a as FolderType]?.order) || 999;
|
|
||||||
const orderB = (FOLDER_CONFIG[b as FolderType]?.order) || 999;
|
|
||||||
return orderA - orderB;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-carnet-sidebar">
|
<div className="flex flex-col h-full bg-carnet-bg border-r border-carnet-border">
|
||||||
{/* Search */}
|
{/* Search Bar */}
|
||||||
<div className="p-4">
|
<div className="p-4 border-b border-carnet-border">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="Recherché..."
|
placeholder="Rechercher..."
|
||||||
className="w-full pl-9 pr-4 py-2 bg-white border border-carnet-border rounded-md text-sm text-carnet-text-primary placeholder-carnet-text-muted focus:outline-none focus:ring-1 focus:ring-primary"
|
className="w-full pl-9 pr-4 py-2 bg-white border border-carnet-border rounded-md text-sm text-carnet-text-primary placeholder-carnet-text-muted focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-carnet-text-muted" />
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-carnet-text-muted" />
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
className="absolute right-3 top-2.5 text-carnet-text-muted hover:text-carnet-text-primary"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Folders */}
|
{/* Folders List */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="space-y-1">
|
<ul className="py-2">
|
||||||
{sortedFolders.map((folder) => {
|
{nextcloudFolders.map((folder) => (
|
||||||
const Icon = getFolderIcon(folder);
|
<li key={folder}>
|
||||||
return (
|
{folder === 'Contacts' ? (
|
||||||
<button
|
<>
|
||||||
key={folder}
|
<button
|
||||||
onClick={() => onFolderSelect(folder)}
|
onClick={() => onFolderSelect(folder)}
|
||||||
className="w-full flex items-center space-x-2 px-3 py-2 text-sm rounded-md text-carnet-text-primary hover:bg-carnet-hover"
|
className="w-full flex items-center px-4 py-2 text-sm text-carnet-text-primary hover:bg-carnet-hover"
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Users className="h-4 w-4 mr-2" />
|
||||||
<span>{folder}</span>
|
<span>{folder}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
{/* Contact Groups */}
|
||||||
})}
|
<ul className="ml-6">
|
||||||
</div>
|
{contactGroups.map((group) => (
|
||||||
|
<li key={group.name}>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroup(group.name)}
|
||||||
|
className="w-full flex items-center px-4 py-2 text-sm text-carnet-text-primary hover:bg-carnet-hover"
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-4 w-4 mr-2 transition-transform ${
|
||||||
|
expandedGroups.has(group.name) ? 'transform rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<Folder className="h-4 w-4 mr-2" />
|
||||||
|
<span>{group.name}</span>
|
||||||
|
</button>
|
||||||
|
{expandedGroups.has(group.name) && (
|
||||||
|
<ul className="ml-4">
|
||||||
|
{group.contacts.map((contact) => (
|
||||||
|
<li key={contact}>
|
||||||
|
<button
|
||||||
|
onClick={() => onFolderSelect(`${folder}/${group.name}/${contact}`)}
|
||||||
|
className="w-full flex items-center px-4 py-2 text-sm text-carnet-text-muted hover:bg-carnet-hover"
|
||||||
|
>
|
||||||
|
<span className="ml-6">{contact}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onFolderSelect(folder)}
|
||||||
|
className="w-full flex items-center px-4 py-2 text-sm text-carnet-text-primary hover:bg-carnet-hover"
|
||||||
|
>
|
||||||
|
{React.createElement(getFolderIcon(folder), { className: "h-4 w-4 mr-2" })}
|
||||||
|
<span>{folder}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user