<script lang="ts"> import { toast } from 'svelte-sonner'; import fileSaver from 'file-saver'; const { saveAs } = fileSaver; import jsPDF from 'jspdf'; import html2canvas from 'html2canvas-pro'; import dayjs from '$lib/dayjs'; import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; dayjs.extend(duration); dayjs.extend(relativeTime); async function loadLocale(locales) { for (const locale of locales) { try { dayjs.locale(locale); break; // Stop after successfully loading the first available locale } catch (error) { console.error(`Could not load locale '${locale}':`, error); } } } // Assuming $i18n.languages is an array of language codes $: loadLocale($i18n.languages); import { goto } from '$app/navigation'; import { onMount, getContext, onDestroy } from 'svelte'; import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores'; import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes'; import { capitalizeFirstLetter } from '$lib/utils'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import Search from '../icons/Search.svelte'; import Plus from '../icons/Plus.svelte'; import ChevronRight from '../icons/ChevronRight.svelte'; import Spinner from '../common/Spinner.svelte'; import Tooltip from '../common/Tooltip.svelte'; import NoteMenu from './Notes/NoteMenu.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import { marked } from 'marked'; const i18n = getContext('i18n'); let loaded = false; let importFiles = ''; let query = ''; let notes = []; let selectedNote = null; let showDeleteConfirm = false; const init = async () => { notes = await getNotes(localStorage.token); }; const createNoteHandler = async () => { const res = await createNewNote(localStorage.token, { title: $i18n.t('New Note'), data: { content: { json: null, html: '', md: '' } }, meta: null, access_control: null }).catch((error) => { toast.error(`${error}`); return null; }); if (res) { goto(`/notes/${res.id}`); } }; const downloadHandler = async (type) => { console.log('downloadHandler', type); console.log('selectedNote', selectedNote); if (type === 'md') { const blob = new Blob([selectedNote.data.content.md], { type: 'text/markdown' }); saveAs(blob, `${selectedNote.title}.md`); } else if (type === 'pdf') { await downloadPdf(selectedNote); } }; const downloadPdf = async (note) => { try { // Define a fixed virtual screen size const virtualWidth = 1024; // Fixed width (adjust as needed) const virtualHeight = 1400; // Fixed height (adjust as needed) // STEP 1. Get a DOM node to render const html = note.data?.content?.html ?? ''; let node; if (html instanceof HTMLElement) { node = html; } else { // If it's HTML string, render to a temporary hidden element node = document.createElement('div'); node.innerHTML = html; document.body.appendChild(node); } // Render to canvas with predefined width const canvas = await html2canvas(node, { useCORS: true, scale: 2, // Keep at 1x to avoid unexpected enlargements width: virtualWidth, // Set fixed virtual screen width windowWidth: virtualWidth, // Ensure consistent rendering windowHeight: virtualHeight }); // Remove hidden node if needed if (!(html instanceof HTMLElement)) { document.body.removeChild(node); } const imgData = canvas.toDataURL('image/png'); // A4 page settings const pdf = new jsPDF('p', 'mm', 'a4'); const imgWidth = 210; // A4 width in mm const pageHeight = 297; // A4 height in mm // Maintain aspect ratio const imgHeight = (canvas.height * imgWidth) / canvas.width; let heightLeft = imgHeight; let position = 0; pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); heightLeft -= pageHeight; // Handle additional pages while (heightLeft > 0) { position -= pageHeight; pdf.addPage(); pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); heightLeft -= pageHeight; } pdf.save(`${note.title}.pdf`); } catch (error) { console.error('Error generating PDF', error); toast.error(`${error}`); } }; const deleteNoteHandler = async (id) => { const res = await deleteNoteById(localStorage.token, id).catch((error) => { toast.error(`${error}`); return null; }); if (res) { init(); } }; const inputFilesHandler = async (inputFiles) => { // Check if all the file is a markdown file and extract name and content for (const file of inputFiles) { if (file.type !== 'text/markdown') { toast.error($i18n.t('Only markdown files are allowed')); return; } const reader = new FileReader(); reader.onload = async (event) => { const content = event.target.result; let name = file.name.replace(/\.md$/, ''); if (typeof content !== 'string') { toast.error($i18n.t('Invalid file content')); return; } // Create a new note with the content const res = await createNewNote(localStorage.token, { title: name, data: { content: { json: null, html: marked.parse(content ?? ''), md: content } }, meta: null, access_control: null }).catch((error) => { toast.error(`${error}`); return null; }); if (res) { init(); } }; reader.readAsText(file); } }; let dragged = false; const onDragOver = (e) => { e.preventDefault(); // Check if a file is being dragged. if (e.dataTransfer?.types?.includes('Files')) { dragged = true; } else { dragged = false; } }; const onDragLeave = () => { dragged = false; }; const onDrop = async (e) => { e.preventDefault(); console.log(e); if (e.dataTransfer?.files) { const inputFiles = Array.from(e.dataTransfer?.files); if (inputFiles && inputFiles.length > 0) { console.log(inputFiles); inputFilesHandler(inputFiles); } } dragged = false; }; onDestroy(() => { console.log('destroy'); const dropzoneElement = document.getElementById('notes-container'); if (dropzoneElement) { dropzoneElement?.removeEventListener('dragover', onDragOver); dropzoneElement?.removeEventListener('drop', onDrop); dropzoneElement?.removeEventListener('dragleave', onDragLeave); } }); onMount(async () => { await init(); loaded = true; const dropzoneElement = document.getElementById('notes-container'); dropzoneElement?.addEventListener('dragover', onDragOver); dropzoneElement?.addEventListener('drop', onDrop); dropzoneElement?.addEventListener('dragleave', onDragLeave); }); </script> <svelte:head> <title> {$i18n.t('Notes')} • {$WEBUI_NAME} </title> </svelte:head> <FilesOverlay show={dragged} /> <div id="notes-container" class="w-full min-h-full h-full"> {#if loaded} <DeleteConfirmDialog bind:show={showDeleteConfirm} title={$i18n.t('Delete note?')} on:confirm={() => { deleteNoteHandler(selectedNote.id); showDeleteConfirm = false; }} > <div class=" text-sm text-gray-500"> {$i18n.t('This will delete')} <span class=" font-semibold">{selectedNote.title}</span>. </div> </DeleteConfirmDialog> <div class="px-4.5 @container h-full pt-2"> {#if Object.keys(notes).length > 0} <div class="pb-10"> {#each Object.keys(notes) as timeRange} <div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2.5"> {$i18n.t(timeRange)} </div> <div class="mb-5 gap-2.5 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4" > {#each notes[timeRange] as note, idx (note.id)} <div class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 bg-gray-50 dark:bg-gray-850 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition" > <div class=" flex flex-1 space-x-4 cursor-pointer w-full"> <a href={`/notes/${note.id}`} class="w-full -translate-y-0.5 flex flex-col justify-between" > <div class="flex-1"> <div class=" flex items-center gap-2 self-center mb-1 justify-between"> <div class=" font-semibold line-clamp-1 capitalize">{note.title}</div> <div> <NoteMenu onDownload={(type) => { selectedNote = note; downloadHandler(type); }} onDelete={() => { selectedNote = note; showDeleteConfirm = true; }} > <button class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" type="button" > <EllipsisHorizontal className="size-5" /> </button> </NoteMenu> </div> </div> <div class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-5 min-h-18" > {#if note.data?.content?.md} {note.data?.content?.md} {:else} {$i18n.t('No content')} {/if} </div> </div> <div class=" text-xs px-0.5 w-full flex justify-between items-center"> <div> {dayjs(note.updated_at / 1000000).fromNow()} </div> <Tooltip content={note?.user?.email ?? $i18n.t('Deleted User')} className="flex shrink-0" placement="top-start" > <div class="shrink-0 text-gray-500"> {$i18n.t('By {{name}}', { name: capitalizeFirstLetter( note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User') ) })} </div> </Tooltip> </div> </a> </div> </div> {/each} </div> {/each} </div> {:else} <div class="w-full h-full flex flex-col items-center justify-center"> <div class="pb-20 text-center"> <div class=" text-xl font-medium text-gray-400 dark:text-gray-600"> {$i18n.t('No Notes')} </div> <div class="mt-1 text-sm text-gray-300 dark:text-gray-700"> {$i18n.t('Create your first note by clicking on the plus button below.')} </div> </div> </div> {/if} </div> <div class="absolute bottom-0 left-0 right-0 p-5 max-w-full flex justify-end"> <div class="flex gap-0.5 justify-end w-full"> <Tooltip content={$i18n.t('Create Note')}> <button class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl" type="button" on:click={async () => { createNoteHandler(); }} > <Plus className="size-4.5" strokeWidth="2.5" /> </button> </Tooltip> <!-- <button class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl" > <SparklesSolid className="size-4" /> </button> --> </div> </div> <!-- {#if $user?.role === 'admin'} <div class=" flex justify-end w-full mb-3"> <div class="flex space-x-2"> <input id="notes-import-input" bind:files={importFiles} type="file" accept=".md" hidden on:change={() => { console.log(importFiles); const reader = new FileReader(); reader.onload = async (event) => { console.log(event.target.result); }; reader.readAsText(importFiles[0]); }} /> <button class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition" on:click={() => { const notesImportInputElement = document.getElementById('notes-import-input'); if (notesImportInputElement) { notesImportInputElement.click(); } }} > <div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Notes')}</div> <div class=" self-center"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-4 h-4" > <path fill-rule="evenodd" d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z" clip-rule="evenodd" /> </svg> </div> </button> </div> </div> {/if} --> {:else} <div class="w-full h-full flex justify-center items-center"> <Spinner /> </div> {/if} </div>