mirror of
https://github.com/arthur-pbty/blocnote.git
synced 2026-06-03 23:36:28 +02:00
Add SVG assets and TypeScript configuration
- Created a new SVG for the Open Graph image (og-image.svg) with a gradient background and text elements. - Added Vercel logo SVG (vercel.svg) for deployment branding. - Introduced a window icon SVG (window.svg) for UI representation. - Initialized TypeScript configuration file (tsconfig.json) with strict settings and module resolution for a React project.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Note } from "../types";
|
||||
import Toolbar from "./Toolbar";
|
||||
import MarkdownPreview from "./MarkdownPreview";
|
||||
import { Pencil, Eye, Columns2 } from "lucide-react";
|
||||
|
||||
type ViewMode = "edit" | "preview" | "split";
|
||||
|
||||
interface EditorProps {
|
||||
note: Note;
|
||||
onUpdateNote: (id: string, updates: Partial<Note>) => void;
|
||||
}
|
||||
|
||||
export default function Editor({ note, onUpdateNote }: EditorProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("split");
|
||||
const [title, setTitle] = useState(note.title);
|
||||
const [content, setContent] = useState(note.content);
|
||||
|
||||
const handleTitleChange = (newTitle: string) => {
|
||||
setTitle(newTitle);
|
||||
onUpdateNote(note.id, { title: newTitle });
|
||||
};
|
||||
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setContent(newContent);
|
||||
onUpdateNote(note.id, { content: newContent });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="editor-container">
|
||||
<div className="editor-header">
|
||||
<input
|
||||
type="text"
|
||||
className="editor-title-input"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Titre de la note..."
|
||||
/>
|
||||
<div className="view-mode-buttons">
|
||||
<button
|
||||
className={`btn-view ${viewMode === "edit" ? "active" : ""}`}
|
||||
onClick={() => setViewMode("edit")}
|
||||
title="Éditeur seul"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn-view ${viewMode === "split" ? "active" : ""}`}
|
||||
onClick={() => setViewMode("split")}
|
||||
title="Vue partagée"
|
||||
>
|
||||
<Columns2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn-view ${viewMode === "preview" ? "active" : ""}`}
|
||||
onClick={() => setViewMode("preview")}
|
||||
title="Aperçu seul"
|
||||
>
|
||||
<Eye size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(viewMode === "edit" || viewMode === "split") && (
|
||||
<Toolbar textareaRef={textareaRef} onContentChange={handleContentChange} />
|
||||
)}
|
||||
|
||||
<div className={`editor-body ${viewMode}`}>
|
||||
{(viewMode === "edit" || viewMode === "split") && (
|
||||
<div className="editor-pane">
|
||||
<label htmlFor="note-editor" className="sr-only">Contenu de la note en Markdown</label>
|
||||
<textarea
|
||||
id="note-editor"
|
||||
ref={textareaRef}
|
||||
className="editor-textarea"
|
||||
value={content}
|
||||
onChange={(e) => handleContentChange(e.target.value)}
|
||||
placeholder="Écrivez en Markdown..."
|
||||
spellCheck={false}
|
||||
aria-label="Contenu de la note"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(viewMode === "preview" || viewMode === "split") && (
|
||||
<div className="preview-pane" role="region" aria-label="Aperçu Markdown" aria-live="polite">
|
||||
<MarkdownPreview content={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { marked } from "marked";
|
||||
|
||||
interface MarkdownPreviewProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function MarkdownPreview({ content }: MarkdownPreviewProps) {
|
||||
const html = useMemo(() => {
|
||||
if (!content) return '<p class="preview-empty">Commencez à écrire pour voir l\'aperçu...</p>';
|
||||
try {
|
||||
return marked.parse(content, { breaks: true, gfm: true }) as string;
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="markdown-preview prose"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Note, SortMode } from "../types";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Pin,
|
||||
Trash2,
|
||||
ArrowDownAZ,
|
||||
Clock,
|
||||
Trash,
|
||||
FileText,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from "lucide-react";
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - ts;
|
||||
if (diff < 60000) return "À l'instant";
|
||||
if (diff < 3600000) return `il y a ${Math.floor(diff / 60000)} min`;
|
||||
if (diff < 86400000) return `il y a ${Math.floor(diff / 3600000)}h`;
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
return d.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
return d.toLocaleDateString("fr-FR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
notes: Note[];
|
||||
activeNoteId: string | null;
|
||||
searchQuery: string;
|
||||
sortMode: SortMode;
|
||||
onSelectNote: (id: string) => void;
|
||||
onCreateNote: () => void;
|
||||
onDeleteNote: (id: string) => void;
|
||||
onTogglePin: (id: string) => void;
|
||||
onSearchChange: (q: string) => void;
|
||||
onSortChange: (mode: SortMode) => void;
|
||||
onDeleteAll: () => void;
|
||||
onExportNote: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
notes,
|
||||
activeNoteId,
|
||||
searchQuery,
|
||||
sortMode,
|
||||
onSelectNote,
|
||||
onCreateNote,
|
||||
onDeleteNote,
|
||||
onTogglePin,
|
||||
onSearchChange,
|
||||
onSortChange,
|
||||
onDeleteAll,
|
||||
onExportNote,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="sidebar sidebar-collapsed">
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title="Ouvrir le panneau"
|
||||
>
|
||||
<PanelLeftOpen size={20} />
|
||||
</button>
|
||||
<button className="btn-icon btn-new-note" onClick={onCreateNote} title="Nouvelle note">
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar" role="navigation" aria-label="Liste des notes">
|
||||
<div className="sidebar-header">
|
||||
<div className="sidebar-title-row">
|
||||
<h1 className="sidebar-title">
|
||||
<FileText size={22} />
|
||||
BlocNote
|
||||
</h1>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setCollapsed(true)}
|
||||
title="Réduire le panneau"
|
||||
>
|
||||
<PanelLeftClose size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="search-bar" role="search">
|
||||
<Search size={16} className="search-icon" aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Rechercher..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="search-input"
|
||||
aria-label="Rechercher dans les notes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-actions">
|
||||
<button className="btn-primary" onClick={onCreateNote}>
|
||||
<Plus size={16} />
|
||||
Nouvelle note
|
||||
</button>
|
||||
<div className="sort-buttons">
|
||||
<button
|
||||
className={`btn-sort ${sortMode === "date" ? "active" : ""}`}
|
||||
onClick={() => onSortChange("date")}
|
||||
title="Trier par date"
|
||||
>
|
||||
<Clock size={14} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn-sort ${sortMode === "alpha" ? "active" : ""}`}
|
||||
onClick={() => onSortChange("alpha")}
|
||||
title="Trier par ordre alphabétique"
|
||||
>
|
||||
<ArrowDownAZ size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="notes-list" role="list" aria-label="Notes">
|
||||
{notes.length === 0 && (
|
||||
<div className="empty-state">
|
||||
{searchQuery ? "Aucun résultat" : "Aucune note"}
|
||||
</div>
|
||||
)}
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className={`note-item ${activeNoteId === note.id ? "active" : ""}`}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
onMouseEnter={() => setHoveredId(note.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
<div className="note-item-content">
|
||||
<div className="note-item-title">
|
||||
{note.pinned && <Pin size={12} className="pin-indicator" />}
|
||||
<span>{note.title || "Sans titre"}</span>
|
||||
</div>
|
||||
<div className="note-item-preview">
|
||||
{note.content.slice(0, 60).replace(/[#*_~`>-]/g, "") || "Note vide..."}
|
||||
</div>
|
||||
<div className="note-item-date">{formatDate(note.updatedAt)}</div>
|
||||
</div>
|
||||
{hoveredId === note.id && (
|
||||
<div className="note-item-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
className={`btn-icon-sm ${note.pinned ? "pinned" : ""}`}
|
||||
onClick={() => onTogglePin(note.id)}
|
||||
title={note.pinned ? "Désépingler" : "Épingler"}
|
||||
>
|
||||
<Pin size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon-sm"
|
||||
onClick={() => onExportNote(note.id)}
|
||||
title="Exporter en .txt"
|
||||
>
|
||||
<FileText size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon-sm btn-danger"
|
||||
onClick={() => onDeleteNote(note.id)}
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{notes.length > 0 && (
|
||||
<div className="sidebar-footer">
|
||||
{!confirmDeleteAll ? (
|
||||
<button
|
||||
className="btn-delete-all"
|
||||
onClick={() => setConfirmDeleteAll(true)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
Tout supprimer
|
||||
</button>
|
||||
) : (
|
||||
<div className="confirm-delete-all">
|
||||
<span>Supprimer toutes les notes ?</span>
|
||||
<button className="btn-confirm-yes" onClick={() => { onDeleteAll(); setConfirmDeleteAll(false); }}>
|
||||
Oui
|
||||
</button>
|
||||
<button className="btn-confirm-no" onClick={() => setConfirmDeleteAll(false)}>
|
||||
Non
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { RefObject } from "react";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Heading,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Code,
|
||||
Link,
|
||||
Minus,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ToolbarProps {
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
onContentChange: (content: string) => void;
|
||||
}
|
||||
|
||||
type FormatAction = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
prefix: string;
|
||||
suffix: string;
|
||||
block?: boolean;
|
||||
defaultText?: string;
|
||||
};
|
||||
|
||||
const FORMAT_ACTIONS: FormatAction[] = [
|
||||
{ icon: <Bold size={16} />, label: "Gras", prefix: "**", suffix: "**", defaultText: "texte en gras" },
|
||||
{ icon: <Italic size={16} />, label: "Italique", prefix: "*", suffix: "*", defaultText: "texte en italique" },
|
||||
{ icon: <Strikethrough size={16} />, label: "Barré", prefix: "~~", suffix: "~~", defaultText: "texte barré" },
|
||||
{ icon: <Heading size={16} />, label: "Titre", prefix: "## ", suffix: "", block: true, defaultText: "Titre" },
|
||||
{ icon: <List size={16} />, label: "Liste à puces", prefix: "- ", suffix: "", block: true, defaultText: "Élément" },
|
||||
{ icon: <ListOrdered size={16} />, label: "Liste numérotée", prefix: "1. ", suffix: "", block: true, defaultText: "Élément" },
|
||||
{ icon: <Quote size={16} />, label: "Citation", prefix: "> ", suffix: "", block: true, defaultText: "Citation" },
|
||||
{ icon: <Code size={16} />, label: "Bloc de code", prefix: "```\n", suffix: "\n```", defaultText: "code" },
|
||||
{ icon: <Link size={16} />, label: "Lien", prefix: "[", suffix: "](url)", defaultText: "texte du lien" },
|
||||
{ icon: <Minus size={16} />, label: "Ligne horizontale", prefix: "\n---\n", suffix: "", defaultText: "" },
|
||||
];
|
||||
|
||||
export default function Toolbar({ textareaRef, onContentChange }: ToolbarProps) {
|
||||
const applyFormat = (action: FormatAction) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = textarea.value;
|
||||
const selected = text.substring(start, end);
|
||||
const hasSelection = selected.length > 0;
|
||||
|
||||
let insertText: string;
|
||||
let cursorPos: number;
|
||||
|
||||
if (action.defaultText === "" && !hasSelection) {
|
||||
// For horizontal rule, just insert without placeholder
|
||||
insertText = action.prefix;
|
||||
cursorPos = start + action.prefix.length;
|
||||
} else if (hasSelection) {
|
||||
if (action.block) {
|
||||
// For block elements, check if we need a newline before
|
||||
const needsNewline = start > 0 && text[start - 1] !== "\n";
|
||||
const nl = needsNewline ? "\n" : "";
|
||||
insertText = nl + action.prefix + selected + action.suffix;
|
||||
cursorPos = start + nl.length + action.prefix.length + selected.length + action.suffix.length;
|
||||
} else {
|
||||
insertText = action.prefix + selected + action.suffix;
|
||||
cursorPos = start + action.prefix.length + selected.length + action.suffix.length;
|
||||
}
|
||||
} else {
|
||||
const placeholder = action.defaultText || "";
|
||||
if (action.block) {
|
||||
const needsNewline = start > 0 && text[start - 1] !== "\n";
|
||||
const nl = needsNewline ? "\n" : "";
|
||||
insertText = nl + action.prefix + placeholder + action.suffix;
|
||||
cursorPos = start + nl.length + action.prefix.length;
|
||||
} else {
|
||||
insertText = action.prefix + placeholder + action.suffix;
|
||||
cursorPos = start + action.prefix.length;
|
||||
}
|
||||
}
|
||||
|
||||
const newText = text.substring(0, start) + insertText + text.substring(end);
|
||||
onContentChange(newText);
|
||||
|
||||
// Restore focus and cursor position
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus();
|
||||
if (hasSelection || action.defaultText === "") {
|
||||
textarea.setSelectionRange(cursorPos, cursorPos);
|
||||
} else {
|
||||
const selectStart = cursorPos;
|
||||
const selectEnd = cursorPos + (action.defaultText?.length || 0);
|
||||
textarea.setSelectionRange(selectStart, selectEnd);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="toolbar" role="toolbar" aria-label="Barre d'outils de mise en forme Markdown">
|
||||
{FORMAT_ACTIONS.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
className="toolbar-btn"
|
||||
onClick={() => applyFormat(action)}
|
||||
title={action.label}
|
||||
type="button"
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user